From 7eb915011626f99fa48b35a6fd45aab6f9e2fa82 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:57:09 +0800 Subject: [PATCH 01/72] Audit retry nonce (#15759) * fix: retry audit posts and add per-record nonce A single post_request attempt meant any transient failure (timeout, DNS, connection reset) silently dropped the audit record. Retry up to 3 times with backoff and log at error level when a record is finally dropped. Retries (and the existing TCP-proxy fallback) can deliver the same record twice; attach a per-record nonce so the api server can dedup. Co-Authored-By: Claude Fable 5 * fix: fail audit posts on http error status post_request discards the status code, so a 5xx from a reverse proxy (e.g. nginx answering 502 while hbbs restarts) or any 4xx rejection was treated as success and the audit record silently dropped without a log line. Add post_request_with_status (same semantics and TCP-proxy fallback as post_request, status preserved; existing callers untouched) and use it for audit posts: 2xx succeeds, transport errors and 5xx retry, 4xx fails immediately since retrying a deterministic rejection cannot help. Co-Authored-By: Claude Fable 5 * fix: report audit posts rejected with 200 error body hbbs maps handler failures (e.g. a database write error) to HTTP 200 with an {"error": ...} body (WebError::ServerError), so the client treated them as success and the audit record was silently dropped. Detect the error body and fail visibly. No retry: the server already consumed the nonce, and persistence failures are the server's job to solve; the client's job is to make the loss visible. Co-Authored-By: Claude Fable 5 * fix: give audit retries a delay long enough to outlive a restart The backoff was 1s then 2s, so all three attempts landed within about three seconds. That does not cover the case the retry exists for: a reverse proxy answering 502 while the api server restarts fails fast, so every attempt hits the same outage and the record is dropped anyway. Use 10s and 30s instead. The window is bounded on the other side - the api server dedups by nonce for five minutes, and a retry arriving after that expired would be stored twice - so the worst case is now about three minutes, leaving room under that limit. Derive the attempt count from the delay table so the two cannot drift apart. Co-Authored-By: Claude Opus 5 (1M context) * fix: retry audit posts the server answered with an error body hbbs reports handler failures as 200 with an {"error": ...} body, and this treated them as final on the grounds that the server had already consumed the record's nonce. That is no longer how the server behaves: it releases the nonce when the write fails, and answers a post whose earlier attempt is still being written with an error as well. Both are exactly the cases where trying again is what gets the record stored, so giving up after the first attempt drops audit records the retry was added to save. Co-Authored-By: Claude Opus 5 (1M context) * fix: bound audit retries by elapsed time, and retry 408 and 429 The comment claimed the retry window fit inside the server's five-minute nonce memory with room to spare, and that was wrong: one attempt is up to 84s, not 12s, because post_request_ retries the TLS handshake up to four times at 12s each before the 36s TCP-proxy fallback. Three of those plus the delays is 292s against a 300s window, and a suspend between attempts stretches the wall clock without any bound at all, so counting attempts cannot bound this. Stop by elapsed time instead: no new attempt starts past 120s, which leaves the last one room to finish well inside the server's window. Also retry 408 and 429. Both are transient - the request timed out upstream, or a proxy is shedding load - but the 5xx test dropped the record after the first attempt. Co-Authored-By: Claude Opus 5 (1M context) * fix: only an empty 2xx body counts as a stored audit The success check was inverted: any 2xx body that failed to parse as an {"error": ...} object was reported as stored. A proxy interposing a 2xx maintenance page, or a malformed error value, therefore ended the retry loop with success and silently dropped the record - the exact loss the retry was added to prevent. The audit handlers' success contract is an empty body, so treat exactly that as success. A nonempty body with a valid error message stays a retryable server error; any other nonempty body is now a retryable "unexpected response body" instead of an accepted store. Both old and new hbbs answer success with an empty body, and no caller reads the returned text, so nothing depends on the previous acceptance. Co-Authored-By: Claude Opus 5 (1M context) * fix: do not start an audit retry past the deadline The deadline was only checked after an attempt returned, so an attempt could still begin up to one backoff delay past it - starting as late as ~150s and landing at ~234s, while the comment claimed no attempt starts past 120s. Re-check after the delay so the stated bound actually holds: the last attempt now starts before 120s and lands by ~204s, inside the server's five-minute nonce window with margin restored. Co-Authored-By: Claude Opus 5 (1M context) * docs: drop a retry rationale the server no longer backs The comment claimed hbbs answers a post whose earlier attempt is still being written with an error, so that retrying it is what stores the record. That stopped being true: hbbs now answers a concurrent duplicate as already stored rather than as retryable, having dropped the in-flight rejection along with the claim state machine it needed. Nothing in the handling changes - a 2xx carrying an {"error": ...} body is still retried, and that is still right, because the server releases the record's nonce when its write fails. Only the half of the rationale the server no longer backs is gone, since this comment is where the contract between the two repos is written down. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Fable 5 --- src/common.rs | 52 ++++++++++++++++++++ src/server/connection.rs | 102 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 2 deletions(-) diff --git a/src/common.rs b/src/common.rs index cd35433e0..592ab2a45 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1405,6 +1405,58 @@ pub async fn post_request(url: String, body: String, header: &str) -> ResultType .await } +/// POST request via TCP proxy, preserving the HTTP status code. +async fn post_request_via_tcp_proxy_status( + url: &str, + body: &str, + header: &str, +) -> ResultType<(u16, String)> { + let headers = parse_simple_header(header); + let resp = tcp_proxy_request("POST", url, body.as_bytes(), headers).await?; + if !resp.error.is_empty() { + bail!("TCP proxy error: {}", resp.error); + } + Ok(( + resp.status as u16, + String::from_utf8_lossy(&resp.body).to_string(), + )) +} + +/// Like `post_request`, but returns the HTTP status code so callers can tell +/// a server-side failure from success. Same fallback rules: on connection +/// failure or 5xx, retry once through the raw TCP proxy when eligible. +pub async fn post_request_with_status( + url: String, + body: String, + header: &str, +) -> ResultType<(u16, String)> { + if should_use_raw_tcp_for_api(&url) { + return post_request_via_tcp_proxy_status(&url, &body, header).await; + } + let http_result = post_request_http(&url, &body, header).await; + let should_fallback = match &http_result { + Err(_) => true, + Ok((status, _)) => *status >= 500, + }; + if should_fallback && can_fallback_to_raw_tcp(&url) { + log::warn!( + "HTTP POST to {} failed or 5xx (result: {:?}), trying TCP proxy fallback", + tcp_proxy_log_target(&url), + http_result + .as_ref() + .map(|(s, _)| *s) + .map_err(|e| e.to_string()), + ); + match post_request_via_tcp_proxy_status(&url, &body, header).await { + Ok(resp) => return Ok(resp), + Err(tcp_err) => { + log::warn!("TCP proxy fallback also failed: {:?}", tcp_err); + } + } + } + http_result +} + #[async_recursion] async fn post_request_( url: &str, diff --git a/src/server/connection.rs b/src/server/connection.rs index 4dc07d366..25d9b6792 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -1506,6 +1506,8 @@ impl Connection { v["uuid"] = json!(crate::encode64(hbb_common::get_uuid())); v["conn_id"] = json!(self.inner.id); v["session_id"] = json!(self.lr.session_id); + // Unique per record; the api server dedups retried posts by it. + v["nonce"] = json!(uuid::Uuid::new_v4().to_string()); allow_err!(self.tx_post_seq.send((url, v))); } @@ -1555,6 +1557,7 @@ impl Connection { "path":path, "is_file":is_file, "info":json!(info).to_string(), + "nonce": uuid::Uuid::new_v4().to_string(), }); tokio::spawn(async move { allow_err!(Self::post_audit_async(url, v).await); @@ -1576,6 +1579,7 @@ impl Connection { v["typ"] = json!(typ as i8); v["info"] = serde_json::Value::String(info.to_string()); v["conn_id"] = json!(self.inner.id()); + v["nonce"] = json!(uuid::Uuid::new_v4().to_string()); if typ == AlarmAuditType::IpWhitelist || typ == AlarmAuditType::IdWhitelist { if let Some(audit_ref) = self.conn_audit_ref() { v["conn_audit_ref"] = json!(audit_ref); @@ -1603,9 +1607,103 @@ impl Connection { ); } - #[inline] async fn post_audit_async(url: String, v: Value) -> ResultType { - crate::post_request(url, v.to_string(), "").await + // Audit records are compliance evidence; retry transport errors and + // 5xx (e.g. a reverse proxy answering while the api server restarts) + // so transient failures don't silently drop them. A 4xx is a + // deterministic rejection and fails immediately. + // + // The delays, not the attempt count, are what cover the case this exists + // for: a proxy answering 502 during a restart fails fast, so without them + // every attempt lands within a few seconds and none outlives the restart. + // + // The window is bounded on the other side: the api server only remembers a + // record's nonce for five minutes, so a retry arriving after that expired + // would be stored a second time. Counting attempts cannot bound it - one + // attempt is already up to 84s (post_request_ retries the TLS handshake up + // to four times at 12s each, then the TCP-proxy fallback adds 36s), and a + // suspend between attempts stretches the wall clock without limit. So stop + // by elapsed time instead, early enough that the last attempt still lands + // inside the server's window. + const RETRY_DEADLINE: Duration = Duration::from_secs(120); + // One delay per retry, so the attempt count follows from the table and the + // two cannot drift apart. + const RETRY_BACKOFF_SECS: [u64; 2] = [10, 30]; + const ATTEMPTS: usize = RETRY_BACKOFF_SECS.len() + 1; + let body = v.to_string(); + let started = Instant::now(); + let mut attempt = 0usize; + loop { + attempt += 1; + let (retryable, err) = + match crate::post_request_with_status(url.clone(), body.clone(), "").await { + Ok((status, text)) => { + if (200..300).contains(&status) { + // Success is an empty body. hbbs reports handler + // failures (e.g. a db write error) as 200 with an + // {"error": ...} body - retryable: the server + // releases the record's nonce when its write fails, + // so trying again is what stores the record. Any + // other nonempty body did not come from the audit + // handler (a proxy interposing a 2xx maintenance + // page, a malformed error) and must not be mistaken + // for storage, so it is retried rather than dropped. + if text.trim().is_empty() { + return Ok(text); + } + let server_err = serde_json::from_str::(&text) + .ok() + .and_then(|v| v.get("error")?.as_str().map(|s| s.to_owned())) + .filter(|e| !e.is_empty()); + let (label, detail) = match &server_err { + Some(e) => ("server error", e.as_str()), + None => ("unexpected response body", text.as_str()), + }; + let brief: String = detail.chars().take(128).collect(); + (true, format!("{}: {}", label, brief)) + } else { + let brief: String = text.chars().take(128).collect(); + // 408 and 429 are the transient 4xx: the request timed + // out upstream, or a proxy is shedding load. Every other + // 4xx is a deterministic rejection and retrying it would + // only delay the log line. + let transient = status >= 500 || status == 408 || status == 429; + (transient, format!("status {}: {}", status, brief)) + } + } + Err(e) => (true, e.to_string()), + }; + let elapsed = started.elapsed(); + if !retryable || attempt >= ATTEMPTS || elapsed >= RETRY_DEADLINE { + log::error!( + "Audit post dropped (attempt {}/{}, {:?} elapsed): {}", + attempt, + ATTEMPTS, + elapsed, + err + ); + bail!("{}", err); + } + log::warn!( + "Audit post failed (attempt {}/{}): {}", + attempt, + ATTEMPTS, + err + ); + // In range by construction: the guard above returns at ATTEMPTS. + time::sleep(Duration::from_secs(RETRY_BACKOFF_SECS[attempt - 1])).await; + // Re-checked after the delay so no attempt starts past the deadline; + // the check above alone would let one begin up to a backoff later. + if started.elapsed() >= RETRY_DEADLINE { + log::error!( + "Audit post dropped (attempt {}/{}, deadline passed during backoff): {}", + attempt, + ATTEMPTS, + err + ); + bail!("{}", err); + } + } } fn set_conn_audit_primary_auth(&mut self, method: ConnAuditPrimaryAuth) { From cc85685b96af6f51df2ac7a1d36a996dddc90bb2 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Wed, 5 Aug 2026 23:58:23 -0300 Subject: [PATCH 02/72] fix(linux): stop losing every inhibitor when the ScreenSaver name is absent (#15772) On Linux, keeping the host awake during an incoming session asks keepawake for three things at once: the display through org.freedesktop.ScreenSaver on the session bus, and idle plus sleep through logind on the system bus. keepawake takes the ScreenSaver one FIRST and abandons the whole request if it fails, and WakeLock::new discarded the error with .ok(). So on any session where that name is missing, RustDesk silently holds NOTHING - not the display inhibit it could not take, and not the logind inhibits it never got to. On a host whose logind IdleAction is not the default, that means the machine can suspend in the middle of an active remote session, with any capture backend. The name is missing on a GNOME login screen. Measured on a GNOME/Wayland GDM greeter: org.freedesktop.ScreenSaver answers "was not provided by any .service files" and cannot be activated, while org.gnome.SessionManager is on the same bus and its idle inhibit works there. Same machine, same state: with it held the output was still lit at 129.9 s of idle, without it the compositor disabled the output after 30.3 s. Disabled, not blanked - an idle compositor releases the CRTC, so there is no scanout left for anything to read. So on the failure path, take both halves separately instead of neither: - ask keepawake again without the display part, which restores the logind idle/sleep inhibits that have nothing to do with the missing session name; - and get the display half from whichever session interface this desktop has, trying org.gnome.SessionManager and then org.freedesktop.PowerManagement. Only the failure path changes: a session where the ScreenSaver inhibit works is untouched. Where no session interface answers, the log now names every one that was tried and the error each returned, which is the whole diagnostic for a desktop nobody here can test on. Verified on a GNOME/Wayland greeter with a live client: the inhibit is taken 86 ms before anything else happens on the connection, and appears to gnome-session as "RustDesk: incoming session (idle)". The PowerManagement entry is NOT verified - it is the interface KDE and XFCE implement, it costs one extra failed call where it is absent, and the log is what will tell us whether it is the right one. --- src/platform/linux.rs | 167 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 158 insertions(+), 9 deletions(-) diff --git a/src/platform/linux.rs b/src/platform/linux.rs index ab6b1879b..06cee3092 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1982,18 +1982,167 @@ mod desktop { } } -pub struct WakeLock(Option); +/// A session-bus idle-inhibit interface, tried in order; the first that answers wins. +/// `org.freedesktop.ScreenSaver` is absent on purpose: that is the one `keepawake` already tried. +struct SessionInhibitTarget { + dest: &'static str, + path: &'static str, + iface: &'static str, + /// GNOME takes `(app_id, xid, reason, flags)`; PowerManagement takes `(app, reason)`. + gnome_shape: bool, + uninhibit: &'static str, +} + +/// `org.gnome.SessionManager.Inhibit` flag 8 = idle only; logout/switch-user/suspend would take +/// away actions the person at the machine should keep. +const GNOME_INHIBIT_IDLE: u32 = 8; + +const SESSION_INHIBIT_TARGETS: &[SessionInhibitTarget] = &[ + // Measured on a GDM greeter: output held 129.9 s with the inhibit, 30.3 s without. + SessionInhibitTarget { + dest: "org.gnome.SessionManager", + path: "/org/gnome/SessionManager", + iface: "org.gnome.SessionManager", + gnome_shape: true, + uninhibit: "Uninhibit", + }, + // What powerdevil and xfce4-power-manager implement. NOT tested here; costs one failed call + // where absent, and the log below names every interface tried. + SessionInhibitTarget { + dest: "org.freedesktop.PowerManagement", + path: "/org/freedesktop/PowerManagement/Inhibit", + iface: "org.freedesktop.PowerManagement.Inhibit", + gnome_shape: false, + uninhibit: "UnInhibit", + }, +]; + +/// Idle inhibit for the case `keepawake` cannot serve: it inhibits `org.freedesktop.ScreenSaver`, +/// which a GDM greeter bus neither provides nor can activate, so its `create()` fails outright. +/// The connection is kept because the inhibit is bound to it: dropping it releases the inhibit. +struct SessionIdleInhibit { + conn: dbus::blocking::Connection, + target: &'static SessionInhibitTarget, + cookie: u32, +} + +impl SessionIdleInhibit { + fn new(reason: &str) -> Option { + let conn = match dbus::blocking::Connection::new_session() { + Ok(conn) => conn, + Err(err) => { + log::info!("wakelock: no session bus for the idle inhibit fallback ({err})"); + return None; + } + }; + let app = crate::get_app_name(); + let mut refused = Vec::new(); + for target in SESSION_INHIBIT_TARGETS { + let res: Result<(u32,), dbus::Error> = { + let proxy = conn.with_proxy( + target.dest, + target.path, + std::time::Duration::from_secs(3), + ); + if target.gnome_shape { + // Inhibit(s app_id, u xid, s reason, u flags) -> u cookie; xid 0 = no window. + proxy.method_call( + target.iface, + "Inhibit", + (app.clone(), 0u32, reason.to_owned(), GNOME_INHIBIT_IDLE), + ) + } else { + proxy.method_call(target.iface, "Inhibit", (app.clone(), reason.to_owned())) + } + }; + match res { + Ok((cookie,)) => { + log::info!( + "wakelock: holding a {} idle inhibit (cookie {cookie})", + target.dest + ); + return Some(Self { + conn, + target, + cookie, + }); + } + Err(err) => refused.push(format!("{}: {err}", target.dest)), + } + } + // Name every interface tried and why it failed: on an untested desktop this log is what + // turns "the screen still blanks" into a report naming the missing interface. + log::info!( + "wakelock: no session idle inhibitor answered, so the compositor may still blank this \ + screen ({})", + refused.join("; ") + ); + None + } +} + +impl Drop for SessionIdleInhibit { + fn drop(&mut self) { + let proxy = self.conn.with_proxy( + self.target.dest, + self.target.path, + std::time::Duration::from_secs(3), + ); + // Best effort: the session manager ties the inhibit to the caller's bus name, so dropping + // `conn` below releases it even if this call does not get through. + let res: Result<(), dbus::Error> = + proxy.method_call(self.target.iface, self.target.uninhibit, (self.cookie,)); + if let Err(err) = res { + log::debug!("wakelock: releasing the idle inhibit by closing the bus instead ({err})"); + } + } +} + +pub struct WakeLock(Option, Option); impl WakeLock { pub fn new(display: bool, idle: bool, sleep: bool) -> Self { - WakeLock( - keepawake::Builder::new() - .display(display) - .idle(idle) - .sleep(sleep) - .create() - .ok(), - ) + match keepawake::Builder::new() + .display(display) + .idle(idle) + .sleep(sleep) + .create() + { + Ok(handle) => WakeLock(Some(handle), None), + Err(err) => { + // Not `.ok()`: a discarded error is how a login screen ran with no inhibitor at + // all and nobody noticed. + log::info!("wakelock: keepawake could not take the inhibit ({err})"); + // keepawake asks for the ScreenSaver inhibit first and abandons the whole request + // if it fails, losing the logind idle/sleep inhibits that stop the HOST suspending + // mid-session. Re-ask without the display part: those are on the system bus. + let system = if idle || sleep { + match keepawake::Builder::new() + .display(false) + .idle(idle) + .sleep(sleep) + .create() + { + Ok(handle) => Some(handle), + Err(err) => { + log::info!( + "wakelock: the logind idle/sleep inhibit did not come back \ + either ({err})" + ); + None + } + } + } else { + None + }; + let session = if display { + SessionIdleInhibit::new("incoming session") + } else { + None + }; + WakeLock(system, session) + } + } } } From f5ab01f8bd779159765bb8fa5ed0b1d82fa9e6bf Mon Sep 17 00:00:00 2001 From: fufesou Date: Thu, 6 Aug 2026 11:16:01 +0800 Subject: [PATCH 03/72] fix(clipboard): win, populate file formats (#15692) * fix(clipboard): win, populate file formats Signed-off-by: fufesou * fix(clipboard): prevent Windows file clipboard OOB access * reduce diffs to master Signed-off-by: fufesou * comments Signed-off-by: fufesou * fix(clipboard): win, OOBs and double free Signed-off-by: fufesou * fix(clipboard): win, check deep copy Signed-off-by: fufesou * comments Signed-off-by: fufesou * fix(clipboard): harden Windows clipboard memory handling - clear HGLOBAL aliases after ownership transfers - validate callback inputs and capability sets - bound file-content responses and close search handles on errors Signed-off-by: fufesou * fix(clipboard): harden Windows cliprdr memory safety - validate clipboard descriptors and response sizes - fix allocation ownership and cleanup paths - synchronize format-map access across callback and STA threads - prevent clipboard format TOCTOU races Signed-off-by: fufesou * Comments on stale remote file formats Signed-off-by: fufesou * fix(clipboard): check pointers before using Signed-off-by: fufesou * fix(clipboard): harden Windows COM error handling - roll back FORMATETC enumeration on deep-copy failure - keep the enumerator constructor internal - propagate IStream seek and read failures Signed-off-by: fufesou * explicity `WIN32_FIND_DATAW` Signed-off-by: fufesou * fix(clipboard): validate format data size and simplify lock cleanup Reject clipboard data exceeding UINT32_MAX before allocation and keep format-map cleanup and lock release within the owning function. Add boundary tests for response data sizes. Signed-off-by: fufesou * fix(clipboard): missing frees Signed-off-by: fufesou --------- Signed-off-by: fufesou --- libs/clipboard/src/windows/wf_cliprdr.c | 672 +++++++++++++++++------- src/client/io_loop.rs | 2 +- 2 files changed, 475 insertions(+), 199 deletions(-) diff --git a/libs/clipboard/src/windows/wf_cliprdr.c b/libs/clipboard/src/windows/wf_cliprdr.c index d918ee1db..c32a20259 100644 --- a/libs/clipboard/src/windows/wf_cliprdr.c +++ b/libs/clipboard/src/windows/wf_cliprdr.c @@ -26,6 +26,7 @@ #define COBJMACROS #include +#include #include #include #include @@ -50,10 +51,17 @@ #define WF_CLIPRDR_MAX_FORMAT_NAME_WCHARS 255u /* Bound the peer-provided UTF-8 scan separately from the converted Windows name. */ #define WF_CLIPRDR_MAX_FORMAT_NAME_UTF8_BYTES (WF_CLIPRDR_MAX_FORMAT_NAME_WCHARS * 4u) +/* File clipboard redirection always advertises the descriptor and contents formats. */ +#define WF_CLIPRDR_FILE_FORMAT_COUNT 2u #define WF_CLIPRDR_COM_LPT_PREFIX_LENGTH 3u static const WCHAR WF_CLIPRDR_SUPERSCRIPT_DIGITS[] = L"\x00B9\x00B2\x00B3"; static const WCHAR WF_CLIPRDR_INVALID_FILE_NAME_CHARS[] = L"<>:\"|?*"; +BOOL wf_cliprdr_format_data_size_valid(SIZE_T size) +{ + return size <= UINT32_MAX; +} + /* Validates the remote descriptor array size after cItems has been read safely. */ static BOOL wf_cliprdr_file_group_descriptor_size_valid(SIZE_T size, UINT count) { @@ -386,6 +394,9 @@ struct wf_clipboard size_t map_size; size_t map_capacity; formatMapping *format_mappings; + /* Protects map replacement by Tokio callbacks against clipboard STA readers. + * ContextSend serializes callback processing, so callback-local reads need no lock. */ + SRWLOCK format_map_lock; UINT32 requestedFormatId; @@ -405,6 +416,7 @@ struct wf_clipboard BOOL req_f_received; UINT32 req_f_conn_id_expected; // connID of the outstanding request UINT32 req_f_stream_id_expected; // streamId of the outstanding request; responses for another are dropped + ULONG req_fsize_expected; // maximum response size of the outstanding request LONG req_f_stream_id_seq; // source of unique per-stream ids size_t nFiles; @@ -425,10 +437,12 @@ typedef struct wf_clipboard wfClipboard; #define WM_CLIPRDR_MESSAGE (WM_USER + 156) #define OLE_SETCLIPBOARD 1 #define DELAYED_RENDERING 2 +#define OLE_EMPTYCLIPBOARD 3 BOOL wf_cliprdr_init(wfClipboard *clipboard, CliprdrClientContext *cliprdr); BOOL wf_cliprdr_uninit(wfClipboard *clipboard, CliprdrClientContext *cliprdr); -BOOL wf_do_empty_cliprdr(wfClipboard *clipboard); +BOOL wf_do_empty_cliprdr(wfClipboard *clipboard, UINT32 connID); +static BOOL wf_empty_cliprdr_on_sta(wfClipboard *clipboard_ctx, UINT32 connID); static BOOL wf_create_file_obj(UINT32 *connID, wfClipboard *clipboard, IDataObject **ppDataObject); static void wf_destroy_file_obj(IDataObject *instance); @@ -445,7 +459,8 @@ static BOOL is_set_by_instance(wfClipboard *clipboard); static void CliprdrDataObject_Delete(CliprdrDataObject *instance); -static CliprdrEnumFORMATETC *CliprdrEnumFORMATETC_New(ULONG nFormats, FORMATETC *pFormatEtc); +static HRESULT CliprdrEnumFORMATETC_New(ULONG nFormats, FORMATETC *pFormatEtc, + CliprdrEnumFORMATETC **ppInstance); static void CliprdrEnumFORMATETC_Delete(CliprdrEnumFORMATETC *instance); static void CliprdrStream_Delete(CliprdrStream *instance); @@ -527,6 +542,9 @@ static HRESULT STDMETHODCALLTYPE CliprdrStream_Read(IStream *This, void *pv, ULO return E_INVALIDARG; clipboard = (wfClipboard *)instance->m_pData; + if (!clipboard) + return E_UNEXPECTED; + *pcbRead = 0; if (instance->m_lOffset.QuadPart >= instance->m_lSize.QuadPart) @@ -1050,6 +1068,9 @@ static HRESULT STDMETHODCALLTYPE CliprdrDataObject_GetData(IDataObject *This, FO wf_cliprdr_reset_streams(instance); instance->m_pStream = streams; instance->m_nStreams = stream_count; + /* pUnkForRelease is NULL, so the caller now owns hGlobal. */ + clipboard->hmem = NULL; + clipboard->hmem_data_len = 0; return S_OK; } else if (instance->m_pFormatEtc[idx].cfFormat == RegisterClipboardFormat(CFSTR_FILECONTENTS)) @@ -1120,6 +1141,8 @@ static HRESULT STDMETHODCALLTYPE CliprdrDataObject_EnumFormatEtc(IDataObject *Th DWORD dwDirection, IEnumFORMATETC **ppenumFormatEtc) { + HRESULT result; + CliprdrEnumFORMATETC *enumerator; CliprdrDataObject *instance = (CliprdrDataObject *)This; if (!instance || !ppenumFormatEtc) @@ -1127,9 +1150,10 @@ static HRESULT STDMETHODCALLTYPE CliprdrDataObject_EnumFormatEtc(IDataObject *Th if (dwDirection == DATADIR_GET) { - *ppenumFormatEtc = (IEnumFORMATETC *)CliprdrEnumFORMATETC_New(instance->m_nNumFormats, - instance->m_pFormatEtc); - return (*ppenumFormatEtc) ? S_OK : E_OUTOFMEMORY; + result = CliprdrEnumFORMATETC_New(instance->m_nNumFormats, + instance->m_pFormatEtc, &enumerator); + *ppenumFormatEtc = (IEnumFORMATETC *)enumerator; + return result; } else { @@ -1226,24 +1250,7 @@ static CliprdrDataObject *CliprdrDataObject_New(UINT32 connID, FORMATETC *fmtetc return instance; error: - if (iDataObject && iDataObject->lpVtbl) - { - free(iDataObject->lpVtbl); - } - if (instance) - { - if (instance->m_pFormatEtc) - { - free(instance->m_pFormatEtc); - } - - if (instance->m_pStgMedium) - { - free(instance->m_pStgMedium); - } - - CliprdrDataObject_Delete(instance); - } + CliprdrDataObject_Delete(instance); return NULL; } @@ -1307,17 +1314,29 @@ static void wf_destroy_file_obj(IDataObject *instance) * IEnumFORMATETC */ -static void cliprdr_format_deep_copy(FORMATETC *dest, FORMATETC *source) +static HRESULT cliprdr_format_deep_copy(FORMATETC *dest, const FORMATETC *source) { + SIZE_T target_device_size; + + if (!dest || !source) + return E_INVALIDARG; + *dest = *source; - if (source->ptd) - { - dest->ptd = (DVTARGETDEVICE *)CoTaskMemAlloc(sizeof(DVTARGETDEVICE)); + if (!source->ptd) + return S_OK; - if (dest->ptd) - *(dest->ptd) = *(source->ptd); - } + dest->ptd = NULL; + target_device_size = source->ptd->tdSize; + if (target_device_size < offsetof(DVTARGETDEVICE, tdData)) + return DV_E_DVTARGETDEVICE; + + dest->ptd = (DVTARGETDEVICE *)CoTaskMemAlloc(target_device_size); + if (!dest->ptd) + return E_OUTOFMEMORY; + + CopyMemory(dest->ptd, source->ptd, target_device_size); + return S_OK; } static HRESULT STDMETHODCALLTYPE CliprdrEnumFORMATETC_QueryInterface(IEnumFORMATETC *This, @@ -1374,15 +1393,40 @@ static ULONG STDMETHODCALLTYPE CliprdrEnumFORMATETC_Release(IEnumFORMATETC *This static HRESULT STDMETHODCALLTYPE CliprdrEnumFORMATETC_Next(IEnumFORMATETC *This, ULONG celt, FORMATETC *rgelt, ULONG *pceltFetched) { + HRESULT result = S_OK; ULONG copied = 0; + LONG start_index; CliprdrEnumFORMATETC *instance = (CliprdrEnumFORMATETC *)This; if (!instance || !celt || !rgelt) return E_INVALIDARG; + start_index = instance->m_nIndex; while ((instance->m_nIndex < instance->m_nNumFormats) && (copied < celt)) { - cliprdr_format_deep_copy(&rgelt[copied++], &instance->m_pFormatEtc[instance->m_nIndex++]); + result = cliprdr_format_deep_copy(&rgelt[copied], + &instance->m_pFormatEtc[instance->m_nIndex]); + if (FAILED(result)) + break; + copied++; + instance->m_nIndex++; + } + + if (FAILED(result)) + { + while (copied > 0) + { + copied--; + if (rgelt[copied].ptd) + { + CoTaskMemFree(rgelt[copied].ptd); + rgelt[copied].ptd = NULL; + } + } + instance->m_nIndex = start_index; + if (pceltFetched != 0) + *pceltFetched = 0; + return result; } if (pceltFetched != 0) @@ -1398,10 +1442,11 @@ static HRESULT STDMETHODCALLTYPE CliprdrEnumFORMATETC_Skip(IEnumFORMATETC *This, if (!instance) return E_INVALIDARG; - if (instance->m_nIndex + (LONG)celt > instance->m_nNumFormats) + if (instance->m_nIndex < 0 || instance->m_nIndex > instance->m_nNumFormats || + celt > (ULONG)(instance->m_nNumFormats - instance->m_nIndex)) return E_FAIL; - instance->m_nIndex += celt; + instance->m_nIndex += (LONG)celt; return S_OK; } @@ -1419,29 +1464,40 @@ static HRESULT STDMETHODCALLTYPE CliprdrEnumFORMATETC_Reset(IEnumFORMATETC *This static HRESULT STDMETHODCALLTYPE CliprdrEnumFORMATETC_Clone(IEnumFORMATETC *This, IEnumFORMATETC **ppEnum) { + HRESULT result; + CliprdrEnumFORMATETC *clone; CliprdrEnumFORMATETC *instance = (CliprdrEnumFORMATETC *)This; if (!instance || !ppEnum) return E_INVALIDARG; - *ppEnum = - (IEnumFORMATETC *)CliprdrEnumFORMATETC_New(instance->m_nNumFormats, instance->m_pFormatEtc); + result = CliprdrEnumFORMATETC_New(instance->m_nNumFormats, instance->m_pFormatEtc, + &clone); + if (FAILED(result)) + { + *ppEnum = NULL; + return result; + } - if (!*ppEnum) - return E_OUTOFMEMORY; - - ((CliprdrEnumFORMATETC *)*ppEnum)->m_nIndex = instance->m_nIndex; + clone->m_nIndex = instance->m_nIndex; + *ppEnum = (IEnumFORMATETC *)clone; return S_OK; } -CliprdrEnumFORMATETC *CliprdrEnumFORMATETC_New(ULONG nFormats, FORMATETC *pFormatEtc) +static HRESULT CliprdrEnumFORMATETC_New(ULONG nFormats, FORMATETC *pFormatEtc, + CliprdrEnumFORMATETC **ppInstance) { ULONG i; - CliprdrEnumFORMATETC *instance; + HRESULT result = E_OUTOFMEMORY; + CliprdrEnumFORMATETC *instance = NULL; IEnumFORMATETC *iEnumFORMATETC; + if (!ppInstance) + return E_INVALIDARG; + + *ppInstance = NULL; if ((nFormats != 0) && !pFormatEtc) - return NULL; + return E_INVALIDARG; instance = (CliprdrEnumFORMATETC *)calloc(1, sizeof(CliprdrEnumFORMATETC)); @@ -1473,13 +1529,18 @@ CliprdrEnumFORMATETC *CliprdrEnumFORMATETC_New(ULONG nFormats, FORMATETC *pForma goto error; for (i = 0; i < nFormats; i++) - cliprdr_format_deep_copy(&instance->m_pFormatEtc[i], &pFormatEtc[i]); + { + result = cliprdr_format_deep_copy(&instance->m_pFormatEtc[i], &pFormatEtc[i]); + if (FAILED(result)) + goto error; + } } - return instance; + *ppInstance = instance; + return S_OK; error: CliprdrEnumFORMATETC_Delete(instance); - return NULL; + return result; } void CliprdrEnumFORMATETC_Delete(CliprdrEnumFORMATETC *instance) @@ -1566,19 +1627,25 @@ static UINT32 get_remote_format_id(wfClipboard *clipboard, UINT32 local_format) { UINT32 i; formatMapping *map; + UINT32 result = local_format; if (!clipboard) return 0; + AcquireSRWLockShared(&clipboard->format_map_lock); for (i = 0; i < clipboard->map_size; i++) { map = &clipboard->format_mappings[i]; if (map->local_format_id == local_format) - return map->remote_format_id; + { + result = map->remote_format_id; + break; + } } + ReleaseSRWLockShared(&clipboard->format_map_lock); - return local_format; + return result; } static BOOL map_ensure_capacity(wfClipboard *clipboard, size_t capacity) @@ -1612,6 +1679,7 @@ static BOOL map_ensure_capacity(wfClipboard *clipboard, size_t capacity) return TRUE; } +/* Requires format_map_lock until the clipboard STA thread has exited. */ static BOOL clear_format_map(wfClipboard *clipboard) { size_t i; @@ -1636,13 +1704,6 @@ static BOOL clear_format_map(wfClipboard *clipboard) return TRUE; } -static UINT wf_cliprdr_server_format_list_fail(wfClipboard *clipboard) -{ - clear_format_map(clipboard); - clipboard->copied = FALSE; - return ERROR_INTERNAL_ERROR; -} - static UINT cliprdr_send_tempdir(wfClipboard *clipboard) { CLIPRDR_TEMP_DIRECTORY tempDirectory; @@ -1700,7 +1761,6 @@ static UINT cliprdr_send_format_list(wfClipboard *clipboard, UINT32 connID) int count = 0; UINT32 index; UINT32 numFormats = 0; - UINT32 formatId = 0; char formatName[1024]; CLIPRDR_FORMAT *formats = NULL; CLIPRDR_FORMAT_LIST formatList = {0}; @@ -1718,6 +1778,13 @@ static UINT cliprdr_send_format_list(wfClipboard *clipboard, UINT32 connID) /* Ignore if other app is holding clipboard */ if (try_open_clipboard(clipboard->hwnd)) { + if (!IsClipboardFormatAvailable(CF_HDROP)) + { + if (!CloseClipboard()) + return ERROR_INTERNAL_ERROR; + return ERROR_SUCCESS; + } + // If current process is running as service with SYSTEM user. // Clipboard api works fine for text, but copying files works no good. // GetLastError() returns various error codes @@ -1729,6 +1796,8 @@ static UINT cliprdr_send_format_list(wfClipboard *clipboard, UINT32 connID) } numFormats = (UINT32)count; + if (numFormats < WF_CLIPRDR_FILE_FORMAT_COUNT) + numFormats = WF_CLIPRDR_FILE_FORMAT_COUNT; formats = (CLIPRDR_FORMAT *)calloc(numFormats, sizeof(CLIPRDR_FORMAT)); if (!formats) @@ -1741,6 +1810,12 @@ static UINT cliprdr_send_format_list(wfClipboard *clipboard, UINT32 connID) // IsClipboardFormatAvailable(CF_HDROP) is checked above UINT fsid = RegisterClipboardFormat(CFSTR_FILEDESCRIPTORW); UINT fcid = RegisterClipboardFormat(CFSTR_FILECONTENTS); + if (!fsid || !fcid) + { + CloseClipboard(); + free(formats); + return ERROR_INTERNAL_ERROR; + } formats[index++].formatId = fsid; formats[index++].formatId = fcid; numFormats = index; @@ -1848,7 +1923,7 @@ UINT wait_response_event(UINT32 connID, wfClipboard *clipboard, HANDLE event, BO if (clipboard->context->IsStopped == TRUE) { - wf_do_empty_cliprdr(clipboard); + wf_do_empty_cliprdr(clipboard, 0); rc = ERROR_INTERNAL_ERROR; } @@ -1939,6 +2014,7 @@ static UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 con clipboard->req_f_received = FALSE; clipboard->req_f_conn_id_expected = connID; clipboard->req_f_stream_id_expected = streamId; + clipboard->req_fsize_expected = nreq; fileContentsRequest.connID = connID; fileContentsRequest.streamId = streamId; @@ -1969,11 +2045,7 @@ static UINT cliprdr_send_response_filecontents( CLIPRDR_FILE_CONTENTS_RESPONSE fileContentsResponse; if (!clipboard || !clipboard->context || !clipboard->context->ClientFileContentsResponse) - { - data = NULL; - size = 0; - msgFlags = CB_RESPONSE_FAIL; - } + return ERROR_INTERNAL_ERROR; fileContentsResponse.connID = connID; fileContentsResponse.streamId = streamId; @@ -2066,11 +2138,12 @@ static LRESULT CALLBACK cliprdr_proc(HWND hWnd, UINT Msg, WPARAM wParam, LPARAM if (clipboard->hmem) { GlobalFree(clipboard->hmem); - clipboard->hmem = NULL; } } - /* Note: GlobalFree() is not needed when success */ + /* SetClipboardData owns hmem on success; the failure path frees it above. */ + clipboard->hmem = NULL; + clipboard->hmem_data_len = 0; break; case WM_DRAWCLIPBOARD: @@ -2137,6 +2210,13 @@ static LRESULT CALLBACK cliprdr_proc(HWND hWnd, UINT Msg, WPARAM wParam, LPARAM break; + case OLE_EMPTYCLIPBOARD: + DEBUG_CLIPRDR("info: OLE_EMPTYCLIPBOARD"); + if (!wf_empty_cliprdr_on_sta(clipboard, (UINT32)(UINT_PTR)lParam)) + DEBUG_CLIPRDR("OLE_EMPTYCLIPBOARD failed for connection %u", + (UINT32)(UINT_PTR)lParam); + break; + case DELAYED_RENDERING: FORMAT_IDS *format_ids = (FORMAT_IDS *)lParam; if (!try_open_clipboard(clipboard->hwnd)) @@ -2163,9 +2243,11 @@ static LRESULT CALLBACK cliprdr_proc(HWND hWnd, UINT Msg, WPARAM wParam, LPARAM if (clipboard->hmem) { GlobalFree(clipboard->hmem); - clipboard->hmem = NULL; } } + /* SetClipboardData owns hmem on success; the failure path frees it above. */ + clipboard->hmem = NULL; + clipboard->hmem_data_len = 0; } if (!CloseClipboard() && GetLastError()) @@ -2426,6 +2508,9 @@ static BOOL wf_cliprdr_array_ensure_capacity(wfClipboard *clipboard) static BOOL wf_cliprdr_add_to_file_arrays(wfClipboard *clipboard, WCHAR *full_file_name, size_t pathLen) { + if (!clipboard || clipboard->nFiles >= WF_CLIPRDR_MAX_STREAMS) + return FALSE; + if (!wf_cliprdr_array_ensure_capacity(clipboard)) return FALSE; @@ -2464,7 +2549,7 @@ static BOOL wf_cliprdr_traverse_directory(wfClipboard *clipboard, WCHAR *Dir, si { HANDLE hFind; WCHAR DirSpec[MAX_PATH]; - WIN32_FIND_DATA FindFileData; + WIN32_FIND_DATAW FindFileData; if (!clipboard || !Dir) return FALSE; @@ -2500,33 +2585,37 @@ static BOOL wf_cliprdr_traverse_directory(wfClipboard *clipboard, WCHAR *Dir, si { WCHAR DirAdd[MAX_PATH]; if (wcslen(Dir) + wcslen(FindFileData.cFileName) + 2 > MAX_PATH) - return FALSE; + goto fail; StringCchCopyW(DirAdd, MAX_PATH, Dir); StringCchCatW(DirAdd, MAX_PATH, L"\\"); StringCchCatW(DirAdd, MAX_PATH, FindFileData.cFileName); if (!wf_cliprdr_add_to_file_arrays(clipboard, DirAdd, pathLen)) - return FALSE; + goto fail; if (!wf_cliprdr_traverse_directory(clipboard, DirAdd, pathLen)) - return FALSE; + goto fail; } else { WCHAR fileName[MAX_PATH]; if (wcslen(Dir) + wcslen(FindFileData.cFileName) + 2 > MAX_PATH) - return FALSE; + goto fail; StringCchCopyW(fileName, MAX_PATH, Dir); StringCchCatW(fileName, MAX_PATH, L"\\"); StringCchCatW(fileName, MAX_PATH, FindFileData.cFileName); if (!wf_cliprdr_add_to_file_arrays(clipboard, fileName, pathLen)) - return FALSE; + goto fail; } } FindClose(hFind); return TRUE; + +fail: + FindClose(hFind); + return FALSE; } static UINT wf_cliprdr_send_client_capabilities(wfClipboard *clipboard) @@ -2563,11 +2652,15 @@ static UINT wf_cliprdr_monitor_ready(CliprdrClientContext *context, const CLIPRDR_MONITOR_READY *monitorReady) { UINT rc; - wfClipboard *clipboard = (wfClipboard *)context->Custom; + wfClipboard *clipboard; if (!context || !monitorReady) return ERROR_INTERNAL_ERROR; + clipboard = (wfClipboard *)context->Custom; + if (!clipboard) + return ERROR_INTERNAL_ERROR; + clipboard->sync = TRUE; rc = wf_cliprdr_send_client_capabilities(clipboard); @@ -2589,9 +2682,15 @@ static UINT wf_cliprdr_server_capabilities(CliprdrClientContext *context, { UINT32 index; CLIPRDR_CAPABILITY_SET *capabilitySet; - wfClipboard *clipboard = (wfClipboard *)context->Custom; + wfClipboard *clipboard; - if (!context || !capabilities) + if (!context || !capabilities || + capabilities->cCapabilitiesSets > 1 || + (capabilities->cCapabilitiesSets == 1 && !capabilities->capabilitySets)) + return ERROR_INTERNAL_ERROR; + + clipboard = (wfClipboard *)context->Custom; + if (!clipboard) return ERROR_INTERNAL_ERROR; for (index = 0; index < capabilities->cCapabilitiesSets; index++) @@ -2632,18 +2731,19 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, if (!clipboard) return ERROR_INTERNAL_ERROR; + AcquireSRWLockExclusive(&clipboard->format_map_lock); if (!clear_format_map(clipboard)) - return ERROR_INTERNAL_ERROR; + goto unlock_fail; clipboard->copied = FALSE; if (formatList->numFormats > WF_CLIPRDR_MAX_FORMATS) - return ERROR_INTERNAL_ERROR; + goto fail; if (formatList->numFormats > 0 && !formatList->formats) - return ERROR_INTERNAL_ERROR; + goto fail; if (!map_ensure_capacity(clipboard, formatList->numFormats)) - return ERROR_INTERNAL_ERROR; + goto fail; clipboard->copied = TRUE; @@ -2665,30 +2765,30 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, if (!wf_cliprdr_bounded_strlen(format->formatName, WF_CLIPRDR_MAX_FORMAT_NAME_UTF8_BYTES, &name_len)) { - return wf_cliprdr_server_format_list_fail(clipboard); + goto fail; } if (name_len == 0) { - return wf_cliprdr_server_format_list_fail(clipboard); + goto fail; } size = MultiByteToWideChar(CP_UTF8, 0, format->formatName, (int)name_len, NULL, 0); if (size <= 0) { - return wf_cliprdr_server_format_list_fail(clipboard); + goto fail; } if ((UINT)size > WF_CLIPRDR_MAX_FORMAT_NAME_WCHARS) { - return wf_cliprdr_server_format_list_fail(clipboard); + goto fail; } mapping->name = calloc((size_t)size + 1, sizeof(WCHAR)); if (!mapping->name) { - return wf_cliprdr_server_format_list_fail(clipboard); + goto fail; } if (MultiByteToWideChar(CP_UTF8, 0, format->formatName, (int)name_len, @@ -2696,13 +2796,13 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, { free(mapping->name); mapping->name = NULL; - return wf_cliprdr_server_format_list_fail(clipboard); + goto fail; } mapping->local_format_id = RegisterClipboardFormatW((LPWSTR)mapping->name); if (mapping->local_format_id == 0) { - return wf_cliprdr_server_format_list_fail(clipboard); + goto fail; } } else @@ -2713,6 +2813,7 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, clipboard->map_size++; } + ReleaseSRWLockExclusive(&clipboard->format_map_lock); if (file_transferring(clipboard)) { @@ -2723,6 +2824,8 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, *p_conn_id = formatList->connID; if (PostMessage(clipboard->hwnd, WM_CLIPRDR_MESSAGE, OLE_SETCLIPBOARD, p_conn_id)) rc = CHANNEL_RC_OK; + else + free(p_conn_id); } } else @@ -2761,11 +2864,14 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, } else { + free(format_ids->formats); + free(format_ids); rc = ERROR_INTERNAL_ERROR; } } else { + free(format_ids); rc = ERROR_INTERNAL_ERROR; } } @@ -2785,6 +2891,13 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, } return rc; + +fail: + clear_format_map(clipboard); +unlock_fail: + clipboard->copied = FALSE; + ReleaseSRWLockExclusive(&clipboard->format_map_lock); + return ERROR_INTERNAL_ERROR; } /** @@ -2797,7 +2910,9 @@ wf_cliprdr_server_format_list_response(CliprdrClientContext *context, const CLIPRDR_FORMAT_LIST_RESPONSE *formatListResponse) { (void)context; - (void)formatListResponse; + + if (!formatListResponse) + return ERROR_INTERNAL_ERROR; if (formatListResponse->msgFlags != CB_RESPONSE_OK) return E_FAIL; @@ -2886,16 +3001,15 @@ wf_cliprdr_server_format_data_request(CliprdrClientContext *context, if (!context || !formatDataRequest) { - rc = ERROR_INTERNAL_ERROR; - goto exit; + return ERROR_INTERNAL_ERROR; } clipboard = (wfClipboard *)context->Custom; - if (!clipboard) + if (!clipboard || !clipboard->context || + !clipboard->context->ClientFormatDataResponse) { - rc = ERROR_INTERNAL_ERROR; - goto exit; + return ERROR_INTERNAL_ERROR; } requestedFormatId = formatDataRequest->requestedFormatId; @@ -2904,8 +3018,11 @@ wf_cliprdr_server_format_data_request(CliprdrClientContext *context, { size_t len; size_t i; + SIZE_T dropFilesSize; + SIZE_T remaining; WCHAR *wFileName; HRESULT result; + BOOL fileListValid = FALSE; LPDATAOBJECT dataObj; FORMATETC format_etc; STGMEDIUM stg_medium; @@ -2930,6 +3047,7 @@ wf_cliprdr_server_format_data_request(CliprdrClientContext *context, if (FAILED(result)) { + IDataObject_Release(dataObj); rc = ERROR_INTERNAL_ERROR; goto exit; } @@ -2938,58 +3056,105 @@ wf_cliprdr_server_format_data_request(CliprdrClientContext *context, if (!dropFiles) { - GlobalUnlock(stg_medium.hGlobal); + clear_file_array(clipboard); ReleaseStgMedium(&stg_medium); - clipboard->nFiles = 0; - goto resp; + IDataObject_Release(dataObj); + rc = ERROR_INTERNAL_ERROR; + goto exit; } clear_file_array(clipboard); - - if (dropFiles->fWide) + /* HGLOBAL layout: + * [DROPFILES header][optional padding][double-NUL-terminated file list] + * ^ offset 0 ^ byte offset pFiles + * pFiles is an offset, not a pointer: + * https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ns-shlobj_core-dropfiles + * Keep remaining in bytes, parse within the HGLOBAL bounds, and accept only + * after the empty terminator is found. */ + dropFilesSize = GlobalSize(stg_medium.hGlobal); + if (dropFilesSize >= sizeof(DROPFILES) && + dropFiles->pFiles >= sizeof(DROPFILES) && + (SIZE_T)dropFiles->pFiles < dropFilesSize) { - /* dropFiles contains file names */ - for (wFileName = (WCHAR *)((char *)dropFiles + dropFiles->pFiles); - (len = wcslen(wFileName)) > 0; wFileName += len + 1) + remaining = dropFilesSize - dropFiles->pFiles; + if (dropFiles->fWide && (dropFiles->pFiles % sizeof(WCHAR)) == 0) { - wf_cliprdr_process_filename(clipboard, wFileName, wcslen(wFileName)); - } - } - else - { - char *p; - for (p = (char *)((char *)dropFiles + dropFiles->pFiles); (len = strlen(p)) > 0; - p += len + 1, clipboard->nFiles++) - { - int cchWideChar; - cchWideChar = MultiByteToWideChar(CP_ACP, MB_COMPOSITE, p, len, NULL, 0); - wFileName = (LPWSTR)calloc(cchWideChar, sizeof(WCHAR)); - if (wFileName) + wFileName = (WCHAR *)((BYTE *)dropFiles + dropFiles->pFiles); + while (remaining >= sizeof(WCHAR)) { - MultiByteToWideChar(CP_ACP, MB_COMPOSITE, p, len, wFileName, cchWideChar); - wf_cliprdr_process_filename(clipboard, wFileName, cchWideChar); - free(wFileName); + if (FAILED(StringCchLengthW( + wFileName, remaining / sizeof(WCHAR), &len))) + break; + if (len == 0) + { + fileListValid = TRUE; + break; + } + if (!wf_cliprdr_process_filename(clipboard, wFileName, len)) + break; + wFileName += len + 1; + remaining -= (len + 1) * sizeof(WCHAR); } - else + } + else if (!dropFiles->fWide) + { + char *name = (char *)dropFiles + dropFiles->pFiles; + while (remaining > 0) { - rc = ERROR_INTERNAL_ERROR; - GlobalUnlock(stg_medium.hGlobal); - ReleaseStgMedium(&stg_medium); - goto exit; + int wideLen; + if (FAILED(StringCchLengthA(name, remaining, &len))) + break; + if (len == 0) + { + fileListValid = TRUE; + break; + } + wideLen = MultiByteToWideChar( + CP_ACP, MB_COMPOSITE, name, (int)len, NULL, 0); + if (wideLen <= 0) + break; + wFileName = (WCHAR *)calloc((size_t)wideLen + 1, sizeof(WCHAR)); + if (!wFileName) + break; + if (MultiByteToWideChar(CP_ACP, MB_COMPOSITE, name, + (int)len, wFileName, wideLen) != wideLen || + !wf_cliprdr_process_filename( + clipboard, wFileName, (size_t)wideLen)) + { + free(wFileName); + break; + } + free(wFileName); + name += len + 1; + remaining -= len + 1; } } } GlobalUnlock(stg_medium.hGlobal); ReleaseStgMedium(&stg_medium); - resp: - // size will not overflow, because size type is size_t (unsigned __int64) - size = 4 + clipboard->nFiles * sizeof(FILEDESCRIPTORW); - groupDsc = (FILEGROUPDESCRIPTORW *)malloc(size); + if (!fileListValid) + { + clear_file_array(clipboard); + IDataObject_Release(dataObj); + rc = ERROR_INTERNAL_ERROR; + goto exit; + } + if (clipboard->nFiles == 0 || + clipboard->nFiles > WF_CLIPRDR_MAX_STREAMS) + { + IDataObject_Release(dataObj); + rc = ERROR_INTERNAL_ERROR; + goto exit; + } + /* FILEGROUPDESCRIPTORW has a variable-length fgd[] tail. */ + size = offsetof(FILEGROUPDESCRIPTORW, fgd) + + clipboard->nFiles * sizeof(FILEDESCRIPTORW); + groupDsc = (FILEGROUPDESCRIPTORW *)calloc(1, size); if (groupDsc) { - groupDsc->cItems = clipboard->nFiles; + groupDsc->cItems = (UINT)clipboard->nFiles; for (i = 0; i < clipboard->nFiles; i++) { @@ -2998,10 +3163,15 @@ wf_cliprdr_server_format_data_request(CliprdrClientContext *context, } buff = groupDsc; + rc = ERROR_SUCCESS; + } + else + { + size = 0; + rc = CHANNEL_RC_NO_MEMORY; } IDataObject_Release(dataObj); - rc = ERROR_SUCCESS; } else { @@ -3021,7 +3191,20 @@ wf_cliprdr_server_format_data_request(CliprdrClientContext *context, else { globlemem = (char *)GlobalLock(hClipdata); - size = (int)GlobalSize(hClipdata); + if (!globlemem) + { + CloseClipboard(); + rc = ERROR_INTERNAL_ERROR; + goto exit; + } + size = GlobalSize(hClipdata); + if (!wf_cliprdr_format_data_size_valid(size)) + { + GlobalUnlock(hClipdata); + CloseClipboard(); + rc = ERROR_INTERNAL_ERROR; + goto exit; + } buff = malloc(size); if (buff) { @@ -3043,6 +3226,9 @@ wf_cliprdr_server_format_data_request(CliprdrClientContext *context, } exit: + if (rc != ERROR_SUCCESS) + size = 0; + if (rc == ERROR_SUCCESS) { response.msgFlags = CB_RESPONSE_OK; @@ -3052,7 +3238,7 @@ exit: response.msgFlags = CB_RESPONSE_FAIL; } response.connID = formatDataRequest->connID; - response.dataLen = size; + response.dataLen = (UINT32)size; response.requestedFormatData = (BYTE *)buff; if (ERROR_SUCCESS != clipboard->context->ClientFormatDataResponse(clipboard->context, &response)) { @@ -3078,7 +3264,7 @@ wf_cliprdr_server_format_data_response(CliprdrClientContext *context, UINT rc = ERROR_INTERNAL_ERROR; BYTE *data; HANDLE hMem; - wfClipboard *clipboard; + wfClipboard *clipboard = NULL; do { @@ -3105,6 +3291,13 @@ wf_cliprdr_server_format_data_response(CliprdrClientContext *context, break; } + if (formatDataResponse->dataLen > 0 && + !formatDataResponse->requestedFormatData) + { + rc = ERROR_INTERNAL_ERROR; + break; + } + hMem = GlobalAlloc(GMEM_MOVEABLE, formatDataResponse->dataLen); if (!hMem) { @@ -3134,6 +3327,8 @@ wf_cliprdr_server_format_data_response(CliprdrClientContext *context, rc = CHANNEL_RC_OK; } while (0); + if (!clipboard) + return rc; if (!SetEvent(clipboard->formatDataRespEvent)) { // If failed to set event, set flag to indicate the event is received. @@ -3170,16 +3365,15 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context, if (!context || !fileContentsRequest) { - rc = ERROR_INTERNAL_ERROR; - goto exit; + return ERROR_INTERNAL_ERROR; } clipboard = (wfClipboard *)context->Custom; - if (!clipboard) + if (!clipboard || !clipboard->context || + !clipboard->context->ClientFileContentsResponse) { - rc = ERROR_INTERNAL_ERROR; - goto exit; + return ERROR_INTERNAL_ERROR; } // If the clipboard is set by the instance, or the file descriptor is from remote, @@ -3299,7 +3493,7 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context, LARGE_INTEGER dlibMove; ULARGE_INTEGER dlibNewPosition; - if (clipboard->nFiles > 0 && + if (clipboard->context->HandleClipboardFiles && clipboard->nFiles > 0 && fileContentsRequest->listIndex == (UINT32)clipboard->first_file_index && fileContentsRequest->nPositionLow == 0 && fileContentsRequest->nPositionHigh == 0) { @@ -3310,8 +3504,11 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context, dlibMove.LowPart = fileContentsRequest->nPositionLow; hRet = IStream_Seek(pStreamStc, dlibMove, STREAM_SEEK_SET, &dlibNewPosition); - if (SUCCEEDED(hRet)) - hRet = IStream_Read(pStreamStc, pData, cbRequested, (PULONG)&uSize); + if (FAILED(hRet)) + goto exit; + hRet = IStream_Read(pStreamStc, pData, cbRequested, (PULONG)&uSize); + if (FAILED(hRet) || uSize > cbRequested) + goto exit; } } else @@ -3338,7 +3535,7 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context, goto exit; } - if (clipboard->nFiles > 0 && + if (clipboard->context->HandleClipboardFiles && clipboard->nFiles > 0 && fileContentsRequest->listIndex == (UINT32)clipboard->first_file_index && fileContentsRequest->nPositionLow == 0 && fileContentsRequest->nPositionHigh == 0) { @@ -3415,7 +3612,7 @@ static UINT wf_cliprdr_server_file_contents_response(CliprdrClientContext *context, const CLIPRDR_FILE_CONTENTS_RESPONSE *fileContentsResponse) { - wfClipboard *clipboard; + wfClipboard *clipboard = NULL; UINT rc = ERROR_INTERNAL_ERROR; do @@ -3443,6 +3640,17 @@ wf_cliprdr_server_file_contents_response(CliprdrClientContext *context, rc = E_FAIL; break; } + if (fileContentsResponse->cbRequested > 0 && + !fileContentsResponse->requestedData) + { + rc = ERROR_INTERNAL_ERROR; + break; + } + if (fileContentsResponse->cbRequested > clipboard->req_fsize_expected) + { + rc = ERROR_INVALID_DATA; + break; + } clipboard->req_fsize = fileContentsResponse->cbRequested; /* @@ -3465,6 +3673,8 @@ wf_cliprdr_server_file_contents_response(CliprdrClientContext *context, rc = CHANNEL_RC_OK; } while (0); + if (!clipboard) + return rc; if (!SetEvent(clipboard->req_fevent)) { // If failed to set event, set flag to indicate the event is received. @@ -3476,10 +3686,31 @@ wf_cliprdr_server_file_contents_response(CliprdrClientContext *context, BOOL is_set_by_instance(wfClipboard *clipboard) { - if (GetClipboardOwner() == clipboard->hwnd || S_OK == OleIsCurrentClipboard(clipboard->data_obj)) { + IDataObject *data_obj = NULL; + BOOL is_current; + + if (!clipboard) + return FALSE; + if (GetClipboardOwner() == clipboard->hwnd) return TRUE; + if (WaitForSingleObject(clipboard->data_obj_mutex, INFINITE) != WAIT_OBJECT_0) + return FALSE; + /* OLE_SETCLIPBOARD may replace data_obj after the mutex is released, so keep + * a temporary COM reference for the OLE call below. */ + data_obj = clipboard->data_obj; + if (data_obj) + IDataObject_AddRef(data_obj); + if (!ReleaseMutex(clipboard->data_obj_mutex)) + { + if (data_obj) + IDataObject_Release(data_obj); + return FALSE; } - return FALSE; + if (!data_obj) + return FALSE; + is_current = OleIsCurrentClipboard(data_obj) == S_OK; + IDataObject_Release(data_obj); + return is_current; } BOOL is_file_descriptor_from_remote() @@ -3507,6 +3738,7 @@ BOOL wf_cliprdr_init(wfClipboard *clipboard, CliprdrClientContext *cliprdr) clipboard->hUser32 = LoadLibraryA("user32.dll"); clipboard->data_obj = NULL; clipboard->copied = FALSE; + InitializeSRWLock(&clipboard->format_map_lock); if (clipboard->hUser32) { @@ -3630,8 +3862,6 @@ BOOL uninit_cliprdr(CliprdrClientContext *context) BOOL empty_cliprdr(CliprdrClientContext *context, UINT32 connID) { wfClipboard *clipboard = NULL; - CliprdrDataObject *instance = NULL; - BOOL rc = FALSE; if (!context) { return FALSE; @@ -3647,67 +3877,113 @@ BOOL empty_cliprdr(CliprdrClientContext *context, UINT32 connID) return FALSE; } - instance = clipboard->data_obj; + return wf_do_empty_cliprdr(clipboard, connID); +} + +BOOL wf_do_empty_cliprdr(wfClipboard *clipboard, UINT32 connID) +{ + if (!clipboard || !clipboard->hwnd) + return FALSE; + + /* Always queue this operation. Besides releasing ContextSend immediately, this + * prevents OpenClipboard from running inside a WM_RENDERFORMAT handler. */ + if (!PostMessage(clipboard->hwnd, WM_CLIPRDR_MESSAGE, + OLE_EMPTYCLIPBOARD, (LPARAM)(UINT_PTR)connID)) + { + DEBUG_CLIPRDR("PostMessage OLE_EMPTYCLIPBOARD failed with 0x%x", GetLastError()); + return FALSE; + } + return TRUE; +} + +static BOOL wf_release_data_obj_if_same(wfClipboard *clipboard_ctx, IDataObject *expected) +{ + if (WaitForSingleObject(clipboard_ctx->data_obj_mutex, INFINITE) != WAIT_OBJECT_0) + return FALSE; + if (clipboard_ctx->data_obj == expected) + { + clipboard_ctx->data_obj = NULL; + wf_destroy_file_obj(expected); + } + return ReleaseMutex(clipboard_ctx->data_obj_mutex); +} + +static BOOL wf_empty_clipboard_on_sta(wfClipboard *clipboard_ctx, IDataObject *instance) +{ + HRESULT current = S_OK; + DWORD clipboard_sequence = GetClipboardSequenceNumber(); + BOOL close_succeeded; + BOOL result = TRUE; + if (instance) { - if (instance->m_connID != connID) + current = OleIsCurrentClipboard(instance); + if (current != S_OK) { - return TRUE; - } - } - - return wf_do_empty_cliprdr(clipboard); -} - -BOOL wf_do_empty_cliprdr(wfClipboard *clipboard) -{ - BOOL rc = FALSE; - if (!clipboard) - { - return FALSE; - } - - clipboard->copied = FALSE; - - if (WaitForSingleObject(clipboard->data_obj_mutex, INFINITE) != WAIT_OBJECT_0) - { - return FALSE; - } - - do - { - if (clipboard->data_obj != NULL) - { - wf_destroy_file_obj(clipboard->data_obj); - clipboard->data_obj = NULL; - } - - /* discard all contexts in clipboard */ - if (!try_open_clipboard(clipboard->hwnd)) - { - DEBUG_CLIPRDR("OpenClipboard failed with 0x%x", GetLastError()); - rc = FALSE; - break; - } - - if (is_file_descriptor_from_remote()) - { - if (!EmptyClipboard()) + if (current != S_FALSE) { - rc = FALSE; + DEBUG_CLIPRDR("OleIsCurrentClipboard failed with 0x%x", current); + result = FALSE; } + else if (!wf_release_data_obj_if_same(clipboard_ctx, instance)) + result = FALSE; + IDataObject_Release(instance); + return result; } - - if (!CloseClipboard()) - { - // critical error!!! - } - rc = TRUE; - } while (0); - - if (!ReleaseMutex(clipboard->data_obj_mutex)) - { - // critical error!!! } - return rc; + + /* Clipboard calls can synchronously dispatch messages to another STA. */ + if (!try_open_clipboard(clipboard_ctx->hwnd)) + { + DEBUG_CLIPRDR("OpenClipboard failed with 0x%x", GetLastError()); + if (instance) + IDataObject_Release(instance); + return FALSE; + } + + /* OpenClipboard stabilizes the contents; do not clear if they changed while opening. */ + if (clipboard_sequence == GetClipboardSequenceNumber() && + (instance || is_file_descriptor_from_remote()) && !EmptyClipboard()) + { + DEBUG_CLIPRDR("EmptyClipboard failed with 0x%x", GetLastError()); + result = FALSE; + } + + close_succeeded = CloseClipboard(); + if (!close_succeeded) + DEBUG_CLIPRDR("CloseClipboard failed with 0x%x", GetLastError()); + if (instance) + { + if (result && !wf_release_data_obj_if_same(clipboard_ctx, instance)) + result = FALSE; + IDataObject_Release(instance); + } + + return close_succeeded && result; +} + +static BOOL wf_empty_cliprdr_on_sta(wfClipboard *clipboard_ctx, UINT32 connID) +{ + CliprdrDataObject *instance; + + if (!clipboard_ctx) + return FALSE; + if (WaitForSingleObject(clipboard_ctx->data_obj_mutex, INFINITE) != WAIT_OBJECT_0) + return FALSE; + + instance = (CliprdrDataObject *)clipboard_ctx->data_obj; + /* Without a tracked object, continue so stale remote file formats can still be cleared. */ + if (connID != 0 && instance && instance->m_connID != connID) + return ReleaseMutex(clipboard_ctx->data_obj_mutex); + + clipboard_ctx->copied = FALSE; + if (instance) + IDataObject_AddRef((IDataObject *)instance); + if (!ReleaseMutex(clipboard_ctx->data_obj_mutex)) + { + if (instance) + IDataObject_Release((IDataObject *)instance); + return FALSE; + } + return wf_empty_clipboard_on_sta(clipboard_ctx, (IDataObject *)instance); } diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index c0eb7fb57..4636c54f8 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -410,7 +410,7 @@ impl Remote { || !self.is_connected || !(server_file_transfer_enabled && file_transfer_enabled)); log::debug!( - "Process clipboard message from system, stop: {}, is_stopping_allowed: {}, view_only: {}, server_file_transfer_enabled: {}, file_transfer_enabled: {}", + "Process clipboard message from system, view_only: {}, stop: {}, is_stopping_allowed: {}, server_file_transfer_enabled: {}, file_transfer_enabled: {}", view_only, stop, is_stopping_allowed, server_file_transfer_enabled, file_transfer_enabled ); if stop { From ddad47925c6f1e429e5dfd930cacad0be1f2721b Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Thu, 6 Aug 2026 01:20:57 -0300 Subject: [PATCH 04/72] =?UTF-8?q?feat(linux):=20DRM/KMS=20direct=20capture?= =?UTF-8?q?=20for=20Wayland=20=E2=80=94=20no=20portal=20consent=20required?= =?UTF-8?q?=20(#15420)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(drm): opt-in DRM/KMS screen capture for Linux/Wayland adds an opt-in `drm` feature for unattended remote access on Wayland: it captures below the compositor via libdrmtap, so there is no xdg-desktop-portal consent dialog and it works at the login screen. off by default. when the feature is off the build is byte-identical. everything is gated behind feature = "drm" or lives only in the separate rustdesk-unattended-wayland deb, whose package name is the informed consent. architecture (agreed with the maintainer): the capture runs inside the root --service, which already holds the privilege it needs, and streams frames to the user --server over a service-scoped _drm ipc channel. libdrmtap is loaded with dlopen at runtime (no link-time dependency, so the base build is unchanged and it still runs on ubuntu 18), and the .so is built in ci from the rustdesk-org/libdrmtap fork and shipped only in the drm deb. no setcap helper. - service: DrmReader reads scanout directly via the dlopen loader; an IpcDrmCapturer serves _drm consumers with a per-connection capture worker; durable availability cache + pre-warm to avoid enumerate/re-probe restarts - capture: multi-display (targets the selected crtc), hardware cursor over _drm, transient-errno retry with a bounded stall, rejects non-32bpp scanouts before the frame copy - robustness: only active, crtc-bound outputs are offered (an unbound crtc_id=0 connector is filtered and a client-selected 0 is refused, both fall back to pipewire); a per-display rapid-rebuild guard demotes a flapping display to pipewire; per-display (not global) zero-frame failure tracking - root-service hardening: bounded frame allocation and a concurrent-connection cap so a malformed scanout or a buggy consumer cannot OOM or thread-exhaust the service; a negative availability verdict expires so displays that appear after startup recover without a --server restart; exactly-one .so selection in the packaging so a stale object is never silently shipped - build: libdrmtap.so cloned at build time from rustdesk-org/libdrmtap main and bundled only for the --drm deb; ci builds a separate rustdesk-unattended-wayland deb (incl. an ubuntu 18.04 container) - DRM_CAPTURE_SECURITY.md: threat model and hardening notes * feat(drm): phase-2 split, pass the dma-buf fd instead of the converted frame move the egl detile and rgba pack out of the root --service and into the unprivileged --server. the root now calls only drmtap_open + drmtap_grab_desc and exports a raw dma-buf fd; the fd rides the _drm channel over SCM_RIGHTS with a small descriptor (geometry, per-plane offsets/pitches, modifier, hdr) instead of the full rgba frame, dropping the per-frame copy. the --server imports the fd with drmtap_open_render + drmtap_convert_dmabuf, keyed by the import-once egl cache, and the render context is created and dropped on the recv thread. the _drm transport moves off Framed (which cannot carry a fd) to a bespoke sendmsg/recvmsg framing (DrmConn) that attaches one SCM_RIGHTS cmsg only when a fd is present and rejects a truncated ancillary message. the split symbols are bound optionally so an older libdrmtap still loads the cpu path, and the whole thing degrades to the cpu BGRA path or PipeWire when no render node is available. pins libdrmtap-sys to =0.4.13 with the Cargo.lock checksum. folds in the DP-MST, ldconfig-restart and per-display PipeWire-fallback review fixes and a udev hotplug refresh. * drm: address the phase-2 split review 1- do not depend on the libdrmtap-sys crate for the pin: its build.rs statically compiles the whole libdrmtap C tree and a CAP_SYS_ADMIN helper and links -ldrm/-lseccomp/-lcap, which defeats the runtime-dlopen model. keep drm a pure dlopen backend and pin the .so by the build.py DRMTAP_REF release tag, guarded by a strict vX.Y.Z regex. drops the now-moot Cargo.lock freshness CI checks. 2- render-node-less consumers no longer lose the stream: the --server signals need_cpu on DrmStart when it cannot open a convert context, and the --service streams the CPU-converted frame path for that connection instead of a dma-buf fd the consumer cannot detile (which used to fall through to a PipeWire path nobody can approve on an unattended seat). 3- mark PipeWire initialized only after every per-display capturer is created, so a partial failure retries instead of the flag falsely reporting a complete init. 4- reject a degenerate (zero width/height) or short CPU frame before it reaches PixelBuffer::new (which derives stride as data.len()/height, dividing by zero). 5- keep the export-ledger epoch at DRM_DISPLAY_GENERATION so a hotplug invalidates cached buffers (elision stays off until the recycled-fb_id inode case is handled). 6- validate the udev uevent source (kernel nl_pid, multicast) with recvmsg so a local process cannot unicast a spoofed drm-change event to the root listener. * drm: second review pass on the phase-2 split 1- make PipeWire init atomic: build every per-display capturer into owned staging first and publish them to CAP_DISPLAY_INFO only after all succeed, so a mid-loop Capturer::new failure neither leaves partial entries (which the next check_init would treat as already-initialized) nor leaks the raw pointers already created. 2- pin the immutable libdrmtap commit, not just the tag: git clone --branch follows a mutable tag, so verify the cloned HEAD equals DRMTAP_SHA in both the CI workflow and build.py, failing on a moved/compromised tag. 3- drop the stale comment claiming a libdrmtap-sys crate pin (the drm backend has no such dependency). * drm: harden the libdrmtap source pin 1- verify the commit-SHA pin on a reused checkout too, not only on a fresh clone: a stale or mismatched third_party/libdrmtap (e.g. from a failed clone) is now removed and the build fails instead of silently reusing unpinned source. 2- default DRMTAP_REPO to the fork that actually publishes the pinned tag, so a clean git clone --branch v0.4.13 resolves (and to the expected commit) instead of failing on a repo that does not carry the tag. * ci: make the pinned libdrmtap commit SHA literal do not let an inherited DRMTAP_SHA override the verified commit in CI, so the tag/commit pair is immutable there. build.py keeps the env override for local forks. * drm: only SHA-verify a git libdrmtap checkout, not a local source tree gate the commit-SHA pin check on third_party/libdrmtap being a git checkout, so a clone (fresh, reused, or a stale/failed one) is still verified, but a non-git source tree a developer placed there on purpose to build unreleased local libdrmtap is used as-is (it has no tag to verify). * build: request the libdrmtap shared_library target explicitly since libdrmtap 0.4.11 the project builds both a shared object and a static archive, so 'meson compile drmtap' is ambiguous. ask for drmtap:shared_library (rustdesk dlopens the .so and never links the archive). * drm: do not reject a non-BGRA scanout on the export side grab_desc exports the raw scanout dma-buf; the unprivileged converter handles every format libdrmtap supports (10-bit XR30/AR30 with tone mapping, HDR, CCS) down to RGBA. The fourcc gate copied from the CPU-mapped grab() wrongly closed the _drm stream for a 10-bit XR30 primary (0x30335258) that convert_dmabuf converts fine -- observed live on an i915 seat scanning out XRGB2101010. Keep the gate only on grab(), whose frame.format is already the converted BGRA. * drm: do not restart-loop a demoted display PipeWire cannot serve DRM and PipeWire do not share a display-index space: DRM enumerates one entry per connector while the portal often exposes a single whole-desktop stream at index 0. When a per-display DRM capture was demoted to PipeWire for a non-primary DRM index, cap_map.get(&display_idx) was None and the bail Err made ServiceTmpl::run retry get_capturer every 1s forever (a multi-monitor restart loop, latent until a display demotes). Degrade to the whole-desktop stream (index 0) PipeWire does provide instead of spinning. Healthy DRM displays return before this and are unaffected. * ci: build the libdrmtap shared_library target explicitly the CI .so-prebuild step used the same bare 'drmtap' meson target that is ambiguous since libdrmtap became both_libraries (0.4.11); ask for drmtap:shared_library, matching build.py. * drm: stop altering the stock (drm-off) Wayland path (review 3.2, 4.6) 3.2: get_capturer_for_display no longer falls back to cap_map[0] for a missing index. CapturerPtr is a bare *mut Capturer cloned by raw-pointer copy, so aliasing one entry to two display_idx values let two video-service threads call frame() on the same Recorder unsynchronised (data race / UB), reachable in a plain build via CaptureDisplays{set:[0,3]}. Restore the exact-index lookup + bail; a demoted DRM index is dropped from the advertised list at the source instead. 4.6: revert check_init to upstream (flag set before the per-display loop, direct insert). The staged-all-or-nothing variant turned a partial per-display failure into a permanent 1Hz retry loop and was not drm-gated. Both restore the drm-off build to byte-identical with upstream. * drm: address review findings 3.1, 4.2, 4.3, 4.4, 4.7 + minors 3.1: snapshot the stock flutter bundle before the CI drm relink and restore it before makepkg, so the official Arch package ships the stock cdylib, not the drm-enabled one. 4.2: wrap the drm block in a failure-tolerant subshell so a drm-only failure no longer aborts the stock deb/rpm/arch publish. 4.3: narrow the publish glob to rustdesk-[0-9]*.deb so the consent-bypass unattended-wayland deb stays an artifact, not on the public release. 4.4: rewrite the three stale DRM_CAPTURE_SECURITY.md statements to the split (default path passes a read-only scanout dma-buf fd over SCM_RIGHTS with an import-once cache; export validation is metadata-only; BGRA-over-the-wire is the fallback) and document that grab_desc's fd is O_RDONLY (DRM_RDWR dropped upstream, dup preserves it). 4.7: only short-circuit to the DRM cursor when it is authoritative (visible, or hidden in a pure-DRM session); fall through to the normal cursor path in a mixed DRM+PipeWire session. minors: thread the deb variant by feature not glob; TODO for the ld.so.conf.d system path; drop a stray blank line. All gated or whitespace so the drm-off build stays byte-identical. * drm: re-authorize the _drm stream per frame and auth the producer (review 3.3, 4.1) 3.3: DRM/KMS capture is not session-scoped -- the worker grabs a CRTC's physical scanout regardless of which session owns the display -- but the peer was authorized only once at accept. Capture the peer uid and re-check it at the top of the forward loop: root is always allowed, any other peer must still be the active-session uid, fail closed otherwise. A session change now tears the stream down within one frame (~33ms) instead of leaking the incoming user's screen to the outgoing user's --server. 4.1: connect_drm accepted any producer. Reject a non-root peer (peer_uid != 0) so a process that won the socket-path race cannot feed the consumer a display list, frames and dma-buf fds while the DRM path suppresses the portal consent prompt. * drm: validate cursor body length and coalesce _drm frames to latest-wins (review 4.1, 4.8) 4.1: the DrmCursor consumer handed the wire body straight to the client, which renders width*height*4 RGBA bytes. Reject a body shorter than that so a truncated cursor cannot make the client read past the buffer. The hidden-cursor sentinel is 0x0 with an empty body, for which the bound is 0 and the check is a no-op. 4.8: the _drm socket is a FIFO, so a consumer that drains slower than we produce (a 4K convert on a modest GPU) fell seconds behind stale frames. Drain the producer channel without blocking each tick and forward only the newest frame; replaced frames drop in place, closing the zero-copy OwnedFd and freeing the CPU-path pixel buffer. Cursor updates stay in order and are never coalesced away. * drm: keep the demoted-display list consistent instead of stretching PipeWire (review 4.5) A DRM display demoted to PipeWire has no geometry-consistent per-connector stream on a multi-monitor host -- the portal exposes a single whole-desktop stream. The fallthrough served that whole-desktop frame while the list still advertised the demoted connector geometry, so the client stretched the frame and offset all input by the connector origin (the primary-index-0 demotion reaches this even after the get_capturer_for_display exact-index fix). Dropping the display from the list is not an option: its position IS the capturer index, so a drop would shift every later display and desync get_capturer_info. So instead: get_display_infos advertises a multi-monitor demoted display OFFLINE at its stable index, and get_capturer_for_display serves the PipeWire fallback only when its rect matches the advertised geometry, else bails. A single-display host still falls through (whole-desktop == that display). All new logic is drm-gated. * drm: bound the _drm body read, stream-scope cursor teardown, refresh a stale verdict, drop dead clear (review 5) - recv_msg_timeout2 only gated the wait for the first byte, so a peer that sent one byte then stalled pinned the task forever. The same budget now also bounds the body read; a body that overruns is a hard error that tears the stream down (recv_msg bodies are small JSON, so a healthy peer never trips it). - The cursor cache is keyed by display index, which a rebuilt stream reuses, so a predecessor exiting after its replacement published a fresh cursor erased it. Stamp each entry with a monotonic per-stream epoch and compare-and-remove on teardown. - ProbeState::Available had no TTL, so an idle hotplug left a phantom display in enumeration. Give it a timestamp and refresh the list off the hot path once it ages past POSITIVE_TTL. The verdict stays true across the refresh (never bounces a live session to the portal) and the probe runs on a background thread (never blocks the async enumeration). - Remove the dead clear(): it is unreferenced, and wiring it into teardown would force the blocking re-probe on the next enumeration that swap_available_displays exists to avoid. * drm: unit-test the bespoke _drm SCM_RIGHTS framing (review 6) The _drm wire format is hand-rolled (length prefix plus an fd bound to the frame first byte) because Framed/BytesCodec cannot carry ancillary data, so it had zero tests. Add pure-userspace coverage over a socketpair: - a control message round-trips with and without an attached fd, and the received fd refers to the same open file (a byte written into the source is read back through it) - a raw length-prefixed body (cursor / CPU-fallback path) round-trips byte-for-byte - a forged length prefix past the JSON cap is rejected at the prefix - surplus fds packed into one cmsg keep only the first and close the rest - a control message truncated past DRM_CMSG_CAP is rejected (MSG_CTRUNC), not consumed - peer_uid_from_fd reads the socket peer credential the producer-auth path relies on * drm: address the self-review findings on the review rework Five defects an adversarial pass found in the previous commits: - refresh_available_async set the single-flight probe guard, then relied on the detached thread to clear it; if thread creation failed (EAGAIN) or the closure unwound, the guard leaked true and froze every future probe. Release it via RAII inside the closure and on a Builder::spawn error. - The _drm per-frame re-auth called the cached active_uid(), which on a cache miss (exactly during a session switch) falls back to a blocking loginctl seat0 lookup -- on the single-threaded _drm runtime, once per frame, a subprocess storm. Use a new cache-only accessor that never blocks and fails closed on a miss, and correct the comment: the stop is bounded by the active-uid cache cadence, not one frame. - set_drm_cursor inserted unconditionally, so a still-draining predecessor stream could overwrite (then delete on teardown) the cursor a replacement stream published for the same index. Make it a compare-and-set that ignores an older epoch. - recv_msg_timeout2 treated a spurious readable() wakeup with nothing consumed as a mid-frame stall and tore the stream down. Track whether any byte was consumed (drm_read_full sets it) and map a zero-progress deadline back to None (re-poll), reserving the hard error for a genuine partial-frame stall. * drm: release the probe single-flight guard via RAII on the cold path too The cold availability probe in is_available acquired DRM_PROBE_IN_FLIGHT and released it with a plain store(false) after a synchronous body; a panic there (e.g. a poisoned DRM_STATE lock) would leak the guard true and freeze both future probes and the refresh path hardened in the previous commit, since they share the guard. Hoist the release into a shared ProbeInFlightGuard used by both the cold probe and the refresh closure, so any exit -- normal, early, or unwinding -- clears it. * drm: source libdrmtap from rustdesk-org, pinned by sha (review 3.4) The dlopened .so is loaded into the CAP_SYS_ADMIN root service, so it should come from the maintainer-owned repo, not a personal fork. rustdesk-org/libdrmtap main is already synced to the exact commit we pin (c9cf0938 = v0.4.13) but carries no release tag, so point both build.py and the CI job at rustdesk-org and track main with the immutable commit pinned via DRMTAP_SHA. The post-clone sha check makes this fail-closed: main moving off the pinned commit fails the build instead of silently swapping the .so. The CI ref guard now accepts a vX.Y.Z tag or main (a loose branch is still rejected). Switch DRMTAP_REF to a tag if rustdesk-org later publishes one. * drm: dlopen libdrmtap by absolute path + unit-test the _drm admission and re-auth (review 5e, 6a) 5e: the deb dropped /usr/lib/rustdesk into /etc/ld.so.conf.d so the private libdrmtap could be found by soname -- a system-wide search-path entry that lets it shadow a system library for every binary on the host, which Debian Policy 10.2 forbids. Resolve it by absolute path (/usr/lib/rustdesk/libdrmtap.so.0) at the dlopen site instead, with the bare sonames kept only as a dev fallback, and drop the ld.so.conf.d file and the ldconfig/try-restart postinst entirely (the .so is present at its absolute path right after unpack, so the pre-warm resolves with no linker-cache step). The dlopen site is this PR's own code, so this is in scope, not a follow-up. 6a: extract the _drm admission bound and the per-frame re-auth decision into pure helpers (drm_conn_admitted, drm_peer_authorized) and unit-test them: admission admits strictly below MAX_DRM_CONNS and rejects at/above it; re-auth passes root always, passes a non-root peer only while it equals the active-session uid, and fails closed on a switched-away, unknown-session, or unknown-peer case. (The /proc/exe-mismatch rejection is exercised by the accept-time authorize call; unit-testing it in isolation would need a second process with a different exe, so it stays an integration concern.) * ci: run the _drm unit tests on every PR (review 6) The _drm unit tests are behind the opt-in drm feature, which the default workspace test job does not build, so they would sit in the tree unrun -- no better than no tests. Add a Linux step to the per-PR ci.yml that runs them with the feature on, alongside the existing ipc/auth tests. drm is a pure runtime-dlopen backend with no link-time deps (no libdrm/EGL/gbm) and the tests are pure userspace (socketpair framing, SCM_RIGHTS, the peer-auth/admission decisions), so this needs no GPU and no extra system packages. The main build/test stays on default features, so the shipped drm-off config remains the primary verified one. * drm: bump the pinned libdrmtap to v0.4.14 Point the DRM capture build at the libdrmtap v0.4.14 release commit (816766dedaba3140c613712ce97aa2614e8899e7) instead of v0.4.13, in build.py and the flutter-build workflow, and correct the scrap Cargo.toml note to describe the actual DRMTAP_SHA anchor. 0.4.14 keeps the same public API, so the dlopen consumer needs no change. * drm: address the consumer review (login-screen uid, frame flow control, hotplug) - Start the login-screen --server as the active seat0 greeter account instead of root, so the DRM capture GPU/EGL convert never loads the vendor GPU userspace in a privileged process. A genuine root graphical session has no lower uid to drop to and stays root, and if the greeter spawn fails we fall back to a root --server so the login screen stays remotable. Gated on the drm feature so the non-drm build is unchanged. - Bound the number of frames in flight on the `_drm` channel: the consumer acks each frame it finishes converting and the producer only sends while it holds credit, waiting on the socket otherwise. Without this the producer kept writing descriptors into the socket faster than a slow convert drained them and the consumer worked through an ever-growing backlog of stale frames. A zero-byte read or write on the ack path is treated as a closed peer rather than as success. - Forward a display list that became empty (last monitor unplugged) instead of dropping it, so the availability cache leaves Available rather than keep advertising removed displays. - On a topology change, invalidate the Wayland geometry cache and reapply the uinput mouse range for the new layout. The refresh runs off the frame-receive loop and is coalesced across the per-display receivers, so a multi-monitor hotplug runs one worker and the final layout wins. - Clear the prefer-CPU-convert hints on a topology change: display indices can be renumbered, so a hint learned for an old index no longer refers to the same physical display. Re-learned on the next convert failure. - Report a non-DRM-backed display when the DRM list is shorter than the sync list or any entry is offline, covering the present-but-demoted case. * drm: log why the uinput refresh worker could not start The worker released its coalescing slot and returned silently when the runtime failed to build, leaving the uinput range stale for the new layout with nothing in the log to explain it. * drm: gate only frames on send credit, never cursor or topology updates The credit check sat at the top of the producer loop and continued on exhaustion, so while a slow convert withheld its ack the loop never reached the code that forwards cursor updates and pushes a changed display list: the remote cursor froze and a hotplug went unreported until credit returned. The comment claimed those were not credit-gated; structurally they were. The loop now always receives and processes producer messages. Only the frame send is gated: when credit is exhausted the newest frame is held back (latest-wins, matching the existing coalescing) and flushed as soon as an ack lands, while cursors and the topology push go out unimpeded. While a frame is held the loop also waits on the socket, so an ack wakes it promptly rather than only when the next frame arrives; both select arms are cancel-safe. * drm: fix three defects in the frame credit gate Follow-up to the previous commit, from an adversarial review of it. - The ack wake-up skipped the coalescing drain. When the socket arm of the select won, there was no message to seed the drain loop with, so the channel was never polled that iteration: a held frame could be sent while a strictly newer one already sat queued, and a queued cursor waited for the next producer message. Seed the loop from the channel when we woke on an ack instead. - The loop could wait while holding a frame it was allowed to send. Credit replenished by the top-of-loop drain was not consulted before entering the select, so the frame waited for the worker's next message; if capture then returned WouldBlock it sat there until the stall teardown. Take whatever is queued without blocking in that case and fall through to the send. - The capture worker no longer had any backpressure. Draining the channel every iteration (needed so cursors keep flowing) means a full channel no longer parks it, so a consumer converting at a fraction of the capture rate made the privileged service keep grabbing frames that were then discarded -- a packed copy per frame on the CPU path, a PRIME export on the dma-buf path. The worker now skips the grab while the task is holding an undeliverable frame, and keeps polling the cursor so the remote pointer stays live. The gate is deliberately conditioned on holding a frame, not merely on having no credit: with nothing held the task blocks in recv() and cannot observe an ack, so gating there would stop the worker feeding it at all. The comment claiming the bounded channel backpressures the worker is corrected. * drm: gate capture on credit alone, and bound the no-credit wait Follow-up to the previous commit, from an adversarial review that modelled the loop with a real runtime, socket pair and worker thread. Gating the worker only while a frame was already held was wrong: those grabs are not wasted work, they keep the held frame fresh, because the coalescing below lets each newer frame supersede it. Pinning the worker at that moment therefore froze whatever frame happened to be in hand when credit ran out and shipped it stale once the ack landed -- measured at ~91ms average staleness against ~2ms with no gate at all. Gating on lack of credit alone, and waiting on the socket whenever credit is out rather than only while holding a frame, keeps the CPU saving (the worker still stops grabbing) with no staleness: the ack resumes the worker and what goes out is a fresh grab. Modelled at 0ms staleness and the same delivered-frame count, with 31 grabs versus 588 ungated. It is deadlock-free because the socket is watched in exactly the states where the gate is set. The no-credit wait is now bounded (5s). While gated the worker does not grab, so it cannot advance its own MAX_STALLED watchdog; a consumer that stopped acking without closing the socket could otherwise hold this connection, its worker thread and the privileged DRM context open indefinitely. * drm: measure the no-credit deadline from the last ack, not the last wake-up The bound added in the previous commit was a timeout on the wait itself, so any wake renewed it -- and cursor messages keep arriving while frames are gated, so a consumer that had stopped acking but still moved its pointer would renew the deadline forever and never be torn down. Track when we last held credit instead and enforce the deadline against that, keeping the wait capped only so we still wake to re-evaluate it when nothing arrives at all. * drm: drop to Unavailable when the background refresh finds no displays The review asked for two things when the last CRTC disappears: push the empty topology to consumers, and stop advertising the removed displays. Only the first was done. The positive-TTL refresh still discarded an empty probe result and kept the previous list, so on an idle host -- where there is no live stream to carry the hotplug push -- enumeration kept reporting displays that were gone, exactly as described. It now transitions to Unavailable on an empty result, matching the hotplug path, while a failed probe (transient open/EACCES, not evidence the displays are gone) keeps the verdict and only restamps it. * drm: do not let a stale availability probe overwrite a newer verdict query_displays() in the background refresh runs unlocked because it is slow, so a hotplug push can publish a newer verdict while it is in flight; the refresh then overwrote it with its own older result. Harmless while it only replaced the list, but the previous commit made an empty result drop to Unavailable, so a probe that started while the monitors were gone could disable DRM on a host whose monitor had since come back. The refresh now samples the stamp of the verdict it is refreshing and publishes only if that stamp is still current. Every publish stamps a fresh Instant, so an unchanged stamp means nothing republished in between -- equivalent to threading a revision counter through every publish site, without having to keep all of them in sync. * drm: track availability publishes with a generation, and hold the probe guard across the whole path Two defects in the previous commit's staleness check. The single-flight guard was still created inside the spawned closure, but that commit added a DRM_STATE lock before the spawn. A poisoned lock there would unwind past the flag with nothing to clear it, leaving DRM_PROBE_IN_FLIGHT set and freezing every future probe. The guard is now taken immediately after the flag is acquired and moved into the closure, so it covers the lock, the probe, and a failed spawn alike. The explicit release on spawn failure is gone with it: it was not merely redundant but wrong, since by then another refresh may have acquired the flag and clearing it would let two probes run at once. The staleness check itself compared Instant stamps, which made correctness depend on an implicit invariant -- that every publish restamps -- spread across ten call sites; a future publish that reused a stamp would defeat it silently. DRM_STATE now carries an explicit generation, bumped by publish_probe_state, which every write to the state goes through. Instants are left to serve only the TTL checks. The failed-probe branch deliberately restamps without bumping: it touches the TTL, not the verdict, so a concurrent probe loses nothing by publishing over it. * drm: convert each display on the GPU that exports it The unprivileged converter opened its render context with drmtap_open_render(NULL), letting libdrmtap auto-select. On a multi-GPU host that can land on a different GPU than the one driving the display, and importing a scanout across vendors can fail permanently on an incompatible tiling modifier. The service already knows the exporting device, so it now names its render node (drmtap_render_node, libdrmtap 0.4.15) in each DrmDisplayInfo, and the consumer opens the converter on that node. The field is serde(default) and empty means auto-select, so a service and a server from mismatched builds still interoperate and a pre-0.4.15 .so degrades to exactly the previous behaviour. The path is realpath-gated to /dev/dri before it is opened, the same gate the capture device gets, since it arrives over IPC. When the named node cannot be opened the converter returns None and the existing need_cpu fallback runs the convert on the exporting GPU service-side, which is the most correct place for it anyway. Added a wire-compat test that a pre-render_node DrmDisplayInfo payload still decodes (empty node) and a current one round-trips the node. * drm: advertise the displays of every GPU, not just the first card A drmtap context is bound to a single DRM device, so the service enumerated one auto-detected card and advertised only its monitors. On a multi-GPU host every display driven by another card was invisible to the client, and its card-local CRTC id could not have been opened through the wrong device anyway. The service now enumerates every card (drmtap_list_devices, libdrmtap 0.4.15), opens one reader per device, and merges their displays into the one list, each tagged with its own card node and render node. DrmStart resolves the chosen index to that display's device + CRTC and the worker reopens the right card; the converter already binds the display's render node. Both new fields are serde(default) and empty means the single auto-detected device, so a pre-0.4.15 .so and a mismatched-build peer keep the previous behaviour exactly. Enumeration replaces the single-reader open in the pre-warm, the udev hotplug refresh, and the per-connection handshake, so a hotplug on any card is picked up and an all-monitors-off state now correctly publishes an empty list. The per-connection cache refresh re-enumerates all cards rather than only the connection's device, so serving one display never drops the others from the next handshake. Verified on a Jetson Orin (its two DRM devices, only card2 driving a display): list_devices reports card2/renderD129 with one display, enumeration produces exactly that display tagged to card2, and card1 (no active CRTC) is skipped - no phantom, no regression on the single-display case. * drm: bump the pinned libdrmtap to v0.4.15 * drm: do not guess the exporting GPU when the host has several render nodes The converter binds the render node the service names for a display, and falls back to auto-selection when that name is empty. An empty name is what an older libdrmtap produces: the service resolves it with drmtap_render_node, which only exists since 0.4.15, and rustdesk dlopens libdrmtap.so.0 by soname, so the runtime library can be older than the one the build was pinned to. Auto-selecting is not safe there. On a single-SoC multi-device host the wrong choice does not fail: a Jetson Orin exports the scanout from nvidia-drm while the first render node belongs to tegra, and importing the scanout on the tegra node SUCCEEDS and yields corrupted pixels. There is no convert error, so the prefer-cpu bit never learns anything and the stream simply looks broken with a clean log. Request the CPU-converted path instead whenever the exporter is unnamed and the host exposes more than one render node: the service converts on the device it already has open, which is correct by construction. Hosts with a single render node have nothing to pick wrong and keep the dma-buf path untouched. Verified on a Jetson Orin Nano, the two-device host: with a libdrmtap that lacks drmtap_render_node the capture used to come through visibly corrupted, and now falls back to the cpu path and renders correctly. With 0.4.15 the service names renderD129 and the dma-buf path is used as before. * drm: name the libdrmtap that was really loaded, and say so when it is stale Two hours went into a corrupted capture whose only symptom was a clean log saying "libdrmtap loaded: /usr/lib/rustdesk/libdrmtap.so.0 (v0.4.15)". The library behind that soname symlink was a pre-release 0.4.15 that reported the version but did not export drmtap_render_node, so the service silently stopped naming the exporting GPU. The log named the symlink it asked for, which is not evidence of anything, and the version it printed came from the library itself, which was the part that lied. Log the file the absolute candidate actually resolves to, and warn when a library reports 0.4.15 or newer while missing drmtap_render_node or drmtap_list_devices, naming that file: a version that claims features the symbols do not back means a stale or pre-release build, and the effect is invisible otherwise. Only the absolute candidate is resolved, because dlopen does not search the process CWD for a bare soname while canonicalize would. Also correct two places that no longer matched the code: the security document still described an /etc/ld.so.conf.d drop-in and an ldconfig trigger that build.py deliberately does not ship (the .so is dlopened by absolute path and the package makes the soname symlink itself), and the comment above the render node lookup still said an unnamed exporter always falls back to auto-selection. * drm: tighten the render-node count and the loader diagnostics Four corrections from a review pass over the previous two commits. Count only a render node whose name is renderD followed by a numeric minor. The prefix test also matched something like renderD.backup, which would have inflated the count and pushed a genuinely single-GPU host onto the CPU path. Log the load only after every required symbol resolved. load() still returns None when one is missing, so announcing success first could print "libdrmtap loaded" and then "libdrmtap not available" for the same library. Name only the capability each absent symbol costs: a library missing just drmtap_render_node loses exporting-GPU selection, one missing just drmtap_list_devices loses multi-GPU enumeration, and the previous wording claimed both were gone in either case. Fix the security document's audit step. The dlopen names the symlink by absolute path and the package registers no linker directory, so a leftover object beside it is not loaded on its own; what matters is where the symlink points, and a leftover only matters as what a stray ldconfig would repoint it to. Ask the auditor to read the symlink target instead. * docs: list every case that selects the CPU-converted frame path The security document described the CPU fallback without saying when it is taken, and the multi-GPU safety fallback added in this branch was not mentioned at all. Enumerate the four cases, including the one where the service could not name the exporting GPU on a host with several render nodes, and note that a single-render-node host keeps the DMA-BUF path. * drm: fetch libdrmtap by commit sha instead of cloning a branch `git clone --depth 1 --branch main` fetches only the tip of that branch, so the moment upstream pushes to libdrmtap `main` the pinned commit is no longer present in the shallow clone at all: the build fails on an unreachable object rather than on a mismatched pin, and it fails for a reason that has nothing to do with the checkout being wrong. In the release workflow the whole block is wrapped so the job stays green, which means the drm deb would simply stop being produced without anyone noticing. Fetch the sha directly instead. No branch or tag name takes part in the build now, so it survives every upstream push and cannot be affected by a ref being moved or repointed. DRMTAP_REF is gone, along with the regex that validated it. The post-fetch sha check stays, with a narrower job: a fetch by sha cannot resolve to anything else, so it now guards a reused checkout left at a different pin, which is exactly what a version bump leaves behind. It still removes that tree so the next run re-fetches cleanly. build.py is now the single source of truth for the pin. * drm: move the drm CI out of the stock workflow, and stop touching scrap/Cargo.toml The instruction was that nothing outside the feature should change while the feature is off, and the runtime code honors that, but the build plumbing did not. Start undoing that. ci.yml goes back to upstream byte for byte. The drm test step it carried now lives in a new workflow that only fires when a drm path changes, so a PR that does not touch this backend pays nothing for it. That new workflow also runs the whole rustdesk-crate test set with the feature on rather than filtering by the `_drm` test names, because the name filter skipped the sibling assertion that bounds `size_of::()`, which the new DmabufDesc variant grows. It gains a second job that fetches libdrmtap at the pinned commit, builds the .so and then asserts the contract the runtime depends on: every symbol the loader resolves, derived from the loader source so the two cannot drift, plus evidence that the EGL detile path is really compiled in. libdrmtap degrades to a CPU-only stub when the egl/glesv2 pkg-config files are absent on a build host, and nothing downstream noticed. Note the check looks for the dlopen target name and the import call, not for DT_NEEDED: EGL is loaded lazily on purpose so the privileged process never links the vendor GL stack, so an ELF-level check reports a false negative on a correct library. libs/scrap/Cargo.toml keeps only the added feature: the unrelated blank line before [dependencies.hwcodec] is restored, and the comment no longer describes DRMTAP_REF, which no longer exists. The feature is now drm = ["wayland"] because all three drm modules live inside the wayland arm of common/mod.rs, so scrap/drm alone compiled nothing; it worked only because the root crate always enables scrap/wayland. * drm: build the unattended-wayland deb in its own workflow, not in the release job flutter-build.yml goes back to upstream byte for byte. Three separate changes to the stock release path disappear with it: the drm variant built inside the release container, the snapshot and restore of the stock flutter bundle that existed only to keep the drm relink out of the archlinux package, and the narrowing of the publish glob to keep the consent-free deb off the public release. The deb now builds in the drm workflow instead, which also removes the failure mode the old placement forced: the whole block had to run in a subshell ending in `|| echo WARN` so a drm-only breakage could not abort the stock publish steps, which meant every failure in it, from the fetch to meson to packaging, kept the job green and silently stopped producing the deb. A separate job can just fail. The bridge generator is a reusable workflow, so this calls the stock one rather than duplicating the codegen. The deb is asserted rather than trusted: build.py can exit 0 without producing a package, so the job checks the file exists and that it carries both the real libdrmtap object and its soname symlink. It stays an artifact and never a release deliverable, and it is built on the runner rather than in the old container the stock debs use, so its glibc floor is higher than a released package. * drm: stop refactoring the shared packaging path in build.py generate_control_file goes back to upstream byte for byte: no extra parameters, no conditional inside it. The variant instead rewrites the control file that function just produced, so everything specific to the consent-free package lives in added code rather than in the shared one. That rewrite fails loudly if either anchor line stops matching, so a future upstream change to the control layout cannot quietly yield a variant deb wearing the stock package name. finalize_deb is gone. It had pulled the tail of both deb builders into one shared helper, which is a refactor of a path the feature has no business touching. Both builders now carry their upstream tail verbatim, with the drm work added as three guarded blocks: stage the library, retarget the control, rename the output. With the feature off, every line is upstream's. Verified rather than argued, by building both packages with this script: the drm deb is Package: rustdesk-unattended-wayland, carries Conflicts, Replaces and Provides on rustdesk, has libdrm2, libegl1 and libgles2 appended to Depends, and ships libdrmtap.so.0.4.15 plus its soname symlink. The stock deb is Package: rustdesk, carries none of those three fields, and contains no libdrmtap file at all. * drm: key per-display state by connector identity, and end a stream whose index moved The service binds a stream to (device, crtc_id), which survives a topology change. Everything on the consumer side addressed it by list index, which does not: drm_enumerate_all_displays concatenates per-card lists, so plugging or unplugging a monitor renumbers every display after it. Two consequences, one live and one remembered. Live: a running stream kept sending monitor A while the advertised list, and so the client layout and the injected-input rect, had come to mean monitor B. It only resolved if the stream happened to fail on its own. The stream now records what it was bound to and ends itself when its index stops meaning that, which routes the change through the rebuild the video service already does. Remembered: the zero-frame failure counts and the prefer-cpu verdicts were keyed by index too, so after a renumbering one monitor could inherit another's demotion or be forced onto the CPU convert path for a mismatch that was never its own. Both are now keyed by device plus connector name. The reasoning was already written down for one of these, in the comment above the prefer-cpu clear, and applied only there. That bulk clear is gone with it. It existed to limit the damage of index aliasing; with identity keys it would instead throw away a correct verdict, which costs a real convert failure to relearn, on every unrelated hotplug. Also fixes the drm workflow to skip the two tests the stock CI already skips. Both need a display server and fail on any headless runner, so the job would have gone red for a reason that has nothing to do with this feature. Verified by running the exact command: 88 tests, including the size_of::() assertion that the old name filter was hiding. * drm: end the session when the captured display changes geometry mid-stream A resolution DECREASE wedged the stream. The encoder is sized once, from CapturerInfo at capturer build time; check_display_changed returns None on Wayland, so the periodic display-changed broadcast never fires there; and convert_to_yuv only bails when the source is LARGER than the destination. A smaller frame therefore passed all three and was encoded into the previous canvas, leaving stale content along the right and bottom edges for the rest of the connection. An increase recovered only by accident, because convert then refused and the service rebuilt. This is ours to contain rather than merely inherited: the DrmDisplaysChanged handler re-broadcasts the new geometry through SYNC_DISPLAYS, so the client layout and the pixels it receives actively disagree, where before there was no topology signal at all. The capturer now records the geometry its session was built with and returns a hard error from frame() when a dequeued frame differs, which routes a shrink through the same rebuild an enlargement already takes. got_frame is set first so a session that did deliver frames is not counted as one of the zero-frame sessions that demote a display to PipeWire. The general fix belongs to the Wayland path rather than to this backend, and is filed separately as #15695. Four tests cover it, the first in this file: the matching size is delivered, a smaller and a larger frame both end the session, and an unknown session size stays out of the way instead of rejecting everything. * drm: refuse a libdrmtap that cannot do the split export The root --service must never load libEGL/libGLESv2: the point of the split is that it exports the scanout dma-buf and the unprivileged --server converts. Two paths could still break that, both because the loader accepted a library too old to export. drm_prewarm() called grab() when the loaded .so had no drmtap_grab_desc, and grab() maps and detiles, so the privileged process pulled in the vendor GL stack at startup, before any consumer had asked for a frame. The per-connection capture loop then did the same for every frame, through the CPU fallback. The version guard could not prevent it: it compared the ABI major only, and this library is still 0.x, so every release it has ever made passed. Add a floor at 0.4.9, where the split entry points landed, and require the three split symbols, which also rejects a build that reports a new enough version without carrying them. That is not hypothetical: a pre-release stamped 0.4.15 shipped without the multi-GPU accessors. Both refusals fall back to PipeWire/portal and say which file and which symbols, at warn level. The split symbols are no longer Options, so the type system carries the guarantee instead of a convention. What is left of the CPU path is only what it was meant to be: the consumer has no render node of its own, or the seat exports no transferable dma-buf. Both are facts about the hardware, with no alternative that keeps the stream, and neither is a property of which file was on the load path. Verified against the real library on i915. With 0.4.15 the export path captures a tiled XR30 scanout and libEGL stays out of /proc/self/maps, while the old grab() branch maps it, so the finding reproduces. A stub reporting 0.4.8 and a stub reporting 0.4.15 without the split symbols are both refused, each with its own diagnostic. The mirrored repr(C) layouts are unchanged across 0.4.9 to 0.4.15, checked field by field against include/drmtap.h at both ends, so the floor costs no compatibility that was real. * drm: move the _drm channel and its producer into src/ipc/drm.rs src/ipc.rs is the file every unrelated IPC change has to be read through, and this branch had grown it from 2227 lines to 4112. Move the DRM half out, into the same #[path] submodule form the file already uses for ipc/auth.rs and ipc/fs.rs, so it lands as ipc/drm.rs beside them. What moves: the two payload structs, the producer that runs in the root --service, and the bespoke SCM_RIGHTS framing the channel needs because Framed/BytesCodec cannot carry ancillary data, plus their tests. What stays is the Data variants, which belong to a shared enum and cannot live anywhere else, and three re-exports so every existing call site keeps the path it already uses. ipc.rs is 2285 lines now, 58 above upstream instead of 1885. The move is content-identical: the only edits are the 39 per-item cfg attributes, redundant now that the module is gated once at its declaration, and the test module cfg that becomes a plain cfg(test). Checked by extracting the moved ranges from the previous commit and comparing them line by line against the new file. Both configs build with no new warnings and the same 92 tests pass, 14 of them the drm ones that moved. * drm: bound the _drm accept path (M1, M2, M8) M1: authorization is now done on the blocking pool. It reads the active session uid, which on a cache miss forks loginctl, and the socket is 0666 so any local uid can make us do it. The same call exists for _service, but this runtime is shared by every live capture stream, so a stall here hitches frames instead of delaying one config sync. M2: the handshake was a loop that ignored unexpected messages, which restarted the ten second budget on each one, so a peer sending junk just inside the timeout held a worker thread and one of the eight connection slots for as long as it liked, and eight of them denied DRM capture entirely. It is one receive now, and anything that is not DrmStart closes the connection: the consumer answers the display list with DrmStart and nothing else, so there is nothing legitimate to skip past. M8: dropped the extra unauthorized-connection warn. log_rejected_service_connection inside the authorization already logs the rejection with the peer and active uid and rate limits it to one line per five seconds, which is exactly what a world-connectable socket needs; the second line had no throttle and handed anyone who can connect an unbounded log write. Both configs build, 92 tests pass. * drm: stop the two states that never settle (M4, M6) M4: a dead producer left the availability verdict positive forever. The background refresh keeps a positive verdict on a failed probe, which is right for one failure and wrong for a run of them: if the root --service dies while this --server lives, every probe fails, the cached list keeps being advertised, and every display restart-loops. Three consecutive failures now drop the verdict to Unknown, not to Unavailable, because the evidence is about the producer and not about the hardware, so the next enumeration probes from scratch. The cold probe also resets its own failure budget on success: it was never reset, so the five strike allowance was spent once per process and a later probe demoted on its first failure. M6: a display that can never be grabbed churned PeerInfo about every 35 seconds for the life of the process, because the cooldown was flat: demote, wait 30 s, get advertised online, burn four sessions in a few seconds, demote again. The cooldown now doubles per demote cycle up to 8 minutes. Recovery is unchanged in the way that matters, since the count is erased the moment the display delivers a frame rather than decaying with time, so a monitor that comes back is served immediately. Also, while changing that map: a zero-frame session on a display with no connector identity was recorded under the empty key, which is the same aliasing H2 removed for indexes, one unidentifiable display would have demoted the next one. It is skipped now, as the comment above it always claimed. Two new tests cover the backoff schedule and the reported 35 second cycle. 94 tests pass, both configs build. * drm: give the DRM uinput update the timeout and the bookkeeping (M3, M6) The DRM path sets the uinput absolute range itself, because it bypasses check_init. That copy awaited update_mouse_resolution raw, and it was missing three things check_init has sixty lines above it. No timeout: uinput set_resolution reads its reply with no timeout of its own, so a hung uinput socket blocked every video-service start on this branch, and wedged the hotplug worker inside rt.block_on with UINPUT_REFRESH_BUSY latched true, after which every later hotplug refresh was silently skipped for the process lifetime. It is bounded at 3 s now, the same bound check_init uses. No bookkeeping: it never called set_wayland_uinput_rect or set_wayland_layout_baseline, which is why the #15601 layout-drift remap never activated on the DRM path. Both are recorded now, and only after a successful apply, so a transient failure is retried rather than remembered as applied. No cache invalidation: the cached Wayland layout can predate compositor changes made while no session was active, which is the case #15601 is about. Dropped first, as check_init does. It also stops reprogramming the device when the range has not changed (M6): a display in a rebuild loop called this about once a second, and reapplying an identical range is an IPC roundtrip plus a uinput reconfiguration under a user who may be at the console. The layout baseline is still re-snapshotted on every call, since it is what the client coordinates are measured against. Left as a separate copy rather than folded into check_init: check_init ships in every Linux build and the standing rule for this feature is that the drm-off build does not change by a line. Both configs build, 94 tests pass. * drm: check the greeter server is alive, not just spawned (M5, M10) M5: the greeter fallback tested the wrong thing. start_server reports whether the SPAWN succeeded, so a greeter account that cannot actually run the server, a nologin shell or a hardened home, leaves a child that exits at once; the loop sees only that the child is gone and respawns it as the greeter forever, never reaching the root fallback, and the login screen becomes un-remotable on a host where it used to work. It now requires the child to still be alive after a one second grace before accepting it. A server that dies later than that is a different, transient failure and the existing restart throttle already bounds it. While there: the whole greeter branch is now inside the drm cfg, so the drm-off build is upstream's single start_server line again rather than a run_as_greeter variable that is always false. M10: two monitors of the same model and resolution whose names do not normalize to the compositor's matched no output at all, so both kept the DRM origin, which is (0,0) for independent CRTCs. The client stacks them and injected coordinates hit the wrong monitor with certainty. Unmatched connectors now take the next free output in layout order, preferring one of the same physical size, and say so in the log. That is at worst a swap of two identically sized rectangles, and the layout stays coherent. The same pass also stops one output being claimed by two connectors, which the unique-resolution rule allowed. The assignment is now a pure function, so the cases are testable without a compositor: five tests cover the naming difference, the identical-monitor case, the double claim, name match beating the fallback, and more connectors than outputs. 99 tests pass, both configs build. * drm: stop reallocating and recopying whole frames (M9) The CPU fallback moved a scanout four times: the producer packed it, the kernel carried it, next_raw allocated and zeroed a fresh buffer to read it into, and the consumer copied that into the slot. At 4K30 the last two are about 8 GB/s of memory traffic that does nothing. next_raw_into reads the body straight into a buffer the caller owns, so the kernel copy lands where the frame is going to live, and resize costs nothing once a buffer has seen one frame of that size. The frame buffers then circulate instead of being freed and reallocated: whatever a new frame displaces goes back on offer, both when the encoder consumes one and when a frame is superseded before anyone reads it. The dma-buf path still copies once, because the convert output is borrowed from the render context and only lives until the next convert, but it copies into a recycled buffer and does it outside the slot lock, so a multi-megabyte memcpy no longer holds the encoder off the slot. Steady state is now one allocation for the whole session on both paths, and the CPU path carries the pixels twice instead of four times. The cursor body reads into its own buffer and is moved into the cursor cache rather than copied; it is small and rare, so it stays out of the frame recycler. Two tests: the raw body round trip now also covers a shorter body reusing the buffer, so a stale tail cannot survive into it, and a new test asserts the frame buffers circulate by allocation identity rather than by inspection. 100 tests pass, both configs build. * drm: the polish list, and a correction to my own ABI floor The version floor I added two commits ago was one release too low. drmtap_open_render and drmtap_convert_dmabuf are 0.4.9, but drmtap_grab_desc is 0.4.10, so a genuine 0.4.9 library passed the version gate and was then refused by the symbol gate with a message that called it a stale or pre-release build, which it is not. The floor is 0.4.10 now, the release where the whole split API exists, and the test lists 0.4.9 among the rejected versions with the reason. ExportLedger is deleted. DRM_FD_ELISION was false, so should_send_fd returned true at its first branch and about sixty lines of eviction and epoch machinery were unreachable, untested, in a security sensitive file. Why it was disabled is worth keeping, so here it is: eliding the fd on an fb_id the converter has already imported looks free, but the kernel can recycle an fb_id onto a different buffer with identical geometry and modifier, and the exporter cannot see the dma-buf inode that would tell the difference, so the elision can serve a stale EGLImage. Sending it is cheap, the converter imports once per buffer and closes the surplus fd, and libdrmtap's own cache keys on fb_id AND inode and can only re-import when it is handed a real fd. That reasoning now lives here instead of in dead code. The rest: - num_planes is clamped on the consumer before it reaches the C descriptor. The producer normalizes it and must be root, so this is only defense in depth, but the wire is the one place the value arrives from another process. - warm_availability returns early on X11. Nothing there can consume a DRM stream, and probing makes the ROOT service open DRM readers, so an X11 host running a drm build was paying that at every startup for a path it can never take. - drm_cursor_id no longer clones the cursor. The cursor service polls it at frame cadence to compare eight bytes, and a 256x256 cursor is 256 KiB. - The premultiplied ARGB pass-through is now documented as matching the XFixes path, since that is why it is correct rather than an oversight. - cfg hygiene: input_service.rs uses all(target_os = "linux", feature = "drm") like every other site, and active_uid_cached is gated with the feature too, which also removes a dead-code warning from drm-off Linux builds. - Nits: DrmConn is pub(crate) like its constructors, new_drm_listener is no longer async with nothing to await, and the two anyhow! plus return Err pairs are bail! as the codebase writes them. - DRM_CAPTURE_SECURITY.md moves to docs/ with the other docs, and its "no privileged child process is ever spawned" claim is corrected: an empty helper_path is not a disable switch in the C, find_helper searches six fixed paths and would exec one if the direct export ever failed. It is unreachable here for two independent reasons, the root service holds CAP_SYS_ADMIN so the direct path succeeds and the package builds no helper at all, and the paths are root-writable only, so the accurate statement is that this package never installs one, not that it can never happen. - The comments that narrated the review rather than the code are rewritten to say what the code does. One of them had also drifted: the convert context is opened before we answer with DrmStart, not before the handshake. Both configs build with no new warnings, 100 tests pass. * drm: one DisplayHealth per connector, and the last index-keyed map The three per-display verdicts are three answers to one question, can this display be captured over DRM right now, and they already fed each other: the rebuild cadence and the zero-frame streak end in the same demotion, and the convert verdict is what keeps a multi-GPU display off the dma-buf path so it never gets there. They are one struct now, keyed by connector identity. This also closes a real leftover from H2. Two of the three maps were re-keyed by identity then; the rapid-rebuild map was not, and stayed keyed by list index. A hotplug that renumbers the list therefore moved a flap verdict onto whichever monitor took that slot, which is the same defect in the third map. There is no index-keyed per-display state left. Behaviour is otherwise the same, with one improvement that falls out of the merge: when a demotion cooldown expires, clearing the streak now keeps the display's other state rather than replacing the whole entry, so a build cadence and a convert verdict survive a retry the way they always should have. One test for the demoted predicate, including that a higher demote count still holds a display that a lower one would have released. 101 tests pass, both configs build. * drm: bound the GITHUB_TOKEN in the drm workflow CodeQL flagged the new workflow for not declaring permissions, which is fair: every job here only checks out, builds and tests, and the artifact up/download in the deb job authenticates with the runtime token rather than this one, so contents: read is the whole requirement. Declared at the workflow level so the reusable bridge workflow it calls inherits the same bound. The stock workflows do not declare it either, but they are upstream's and this feature does not touch them; a new file can start out right. * drm: make the outer handshake budget dominate the inner one Two findings from the review bot on our own fork, both worth taking. The caller waited HANDSHAKE_TIMEOUT_MS + 500 for the receive thread to hand back the display list, but that thread is allowed to spend more than that: the connect budget, and then recv_msg_timeout2 applies its argument twice in the worst case, once waiting for the first byte and once for the body. So on a slow connect the outer timer fired first and abandoned a handshake that was still inside its own budget. The wait is now derived from those parts rather than written as a constant, so changing either one cannot silently invert the relationship again, and the two connect sites use the named constant instead of a literal. The cursor cache insert shadowed hcursor under a cfg, so the same line meant the requested id in one build and the served id in the other. It is a separate name now, with the reason on it. Not taken, and why: the bot also suggested making DrmCursorData carry width and height as u32 to match the wire. They are i32 because that is what they feed, protobuf CursorData declares both as int32 and platform/linux.rs assigns them straight across. One cast has to exist somewhere, and it belongs at the boundary where the values are already being validated, not at the consumer. 101 tests pass, both configs build. * drm: bound the body read, and stop the empty key from aliasing displays From the second review bot on our fork. Two of these are real and one of them is mine from earlier today. A raw body read had no deadline. Only the header was bounded, and drm_read_full loops on readable() until it has the exact length, so a producer that wrote a header and then stopped (crashed, stopped, wedged) pinned the consumer receive thread forever. That thread is also the one that observes the stop flag, so every capturer rebuild would have stranded another thread and its render context. The whole body is bounded now, and an overrun is a hard error because the header is already consumed and the frame cannot be resumed. get_capturer_info collapsed an unknown connector identity to the empty string and then read and wrote the health map under it, so two unidentifiable displays shared one entry and one could demote the other. That is exactly the aliasing frame() refuses to take part in; I fixed one side of it this morning and left the other. The key is an Option now and both blocks skip when it is None: a display with no identity simply carries no health. Also from the same pass, smaller: - build.py validates the shape of DRMTAP_SHA and DRMTAP_REPO before they reach a shell command. Both are env-overridable and get interpolated, and beyond the injection argument, an abbreviated sha would defeat the point of pinning while failing in a much less obvious place. - the workflow's push path list is now identical to the pull_request one. It was missing four paths, so a push to master touching only those would have skipped re-verification. - the checkouts set persist-credentials: false, so the token does not stay in .git/config for the rest of the job. - a concurrency group supersedes a stale PR run, but never cancels a master run, whose whole purpose is to record that a commit was verified. Not taken: reading VCPKG_COMMIT_ID and FLUTTER_VERSION from a shared .env. There is no .env at the repo root, and the stock ci.yml and flutter-build.yml hardcode those same two values, so this matches what is already there. 101 tests pass, both configs build. * drm: test the half of the accept-time authorization that had none The review called the accept-time authorization decision the single most important invariant in this PR, and noted it has no test. Half of it did: drm_peer_authorized_matrix covers the uid rule. The other half, the /proc//exe identity match that stops a DIFFERENT program running as the right uid from being handed the screen, did not. We said last round that testing it needs a second process with a different executable, so it was integration rather than unit work. That was too pessimistic: the negative case needs ANY foreign executable, not a second build of rustdesk, and /bin/sleep is one. So the test covers all three outcomes: our own pid matches, a live process running another binary is rejected, and a peer whose pid cannot be resolved is rejected rather than admitted. The test synchronizes on the child having exec'd before it looks. spawn returns while the child is still a copy of us, and until exec completes /proc//exe points at OUR binary, so reading it too early sees a match and the assertion passes for the wrong reason. It failed exactly that way under the parallel suite and passed when run alone. A real peer has necessarily exec'd and connected before it can be authorized, so the window exists only in the test. 102 tests pass, three consecutive full runs, both configs build. * drm: make the refresh decision a pure function, and test it The review named two untested things: the accept-time authorization decision, covered by the previous commit, and the availability/demotion state machine. The demotion half got tests with the backoff work; this is the other half, what a completed background refresh decides. It is extracted rather than tested in place on purpose. The effects touch process-global state, DRM_STATE and the failure counter, which parallel tests cannot share, so a test driving them would be intermittent by construction, which is the kind of test nobody ends up trusting. The decision itself has no such problem, so it is now a total function over the probe result and the consecutive failure count, and the closure applies it. Two tests: the decision table, including that a run short of the threshold keeps a working verdict and the threshold gives it up; and the symptom the policy exists for, a root service that dies while this server lives, where every probe fails from then on and the verdict has to be given up in bounded time, to Unknown rather than Unavailable, because what we learned is about the producer and not about the hardware. 104 tests pass, both configs build. * drm: count a display whose frames never match its advertised size The display list carries the CRTC mode and a frame carries the scanout framebuffer. Those are two different numbers whenever a CRTC scales a smaller buffer up to its mode, so such a display fails the geometry guard on the FIRST frame of every session, having delivered nothing. That path marked the session as having produced frames, which is what the zero-frame streak uses to decide a display cannot be served over DRM at all. So the demotion to PipeWire never armed and the display rebuilt until the rapid-rebuild guard caught it seconds later, under a message about a mid-session change that never happened. Count it instead, through the same bookkeeping the stream-died path uses (now one helper, so the two cannot drift), and say which of the two cases the error is. The unit test asserted the old behaviour on a capturer that had never delivered a frame, so it is split into the mid-session case it meant to cover and the first-frame case it was silently locking in. * drm: make an unpinned libdrmtap deliberate, and reject --drm off Linux Three ways to build a different libdrmtap than the pinned one (DRMTAP_REPO, DRMTAP_SHA, DRMTAP_PREBUILT_DIR) were each silent, and the last skips the sha verification entirely. The claim this feature rests on is that the privileged capture library is the reviewed object at the pinned sha, so any build that is not that one now has to say so: the overrides still work and still cover local work and cross-builds, but they need DRMTAP_ALLOW_UNPINNED=1 alongside them and the build prints what it did. --drm on Windows or macOS was accepted and then dropped by get_features(), so it produced a stock build that looked like a DRM one. Reject it. Also test the _drm body-read deadline, which nothing exercised: the header and the body are separate reads, so the caller budget does not cover the second one and a regression there would silently reopen the stall. * drm: treat an empty DRMTAP_PREBUILT_DIR as unset in the pin gate build_libdrmtap_so() tests it for truthiness, so an empty value means no prebuilt directory. The gate compared it against None instead, and would have demanded the opt-in for an override that was never going to happen. * drm: never latch the uinput refresh slot, and bound the source stride The uinput refresh worker released UINPUT_REFRESH_BUSY on its two normal exits only. The body locks several process-wide mutexes and does a Wayland roundtrip, so an unwind there left the flag set for the process lifetime, and every later hotplug then skipped the spawn and never reapplied the uinput ABS range: the stale-range, wrong-output symptom the refresh exists to prevent. This file already had the answer for the probe flag, one screen away, and the hazard is called out in wayland.rs. Fixing one site and not the other is the same miss as the hotplug maps. The slot is deliberately handed back and re-taken mid-loop, so the guard tracks ownership rather than releasing unconditionally: a plain RAII drop would clear a flag a replacement worker owns. drm_reader bounded only the destination (w*4*h) while the row loop reads up to (h-1)*stride + w*4, so a large stride read past the mapping and could overflow usize in y*stride. drm_render::convert already bounds stride*h; the privileged half must not be the weaker of the two. Also give the drm CI jobs a timeout, so a hung meson or vcpkg step fails in an hour instead of six. * drm: refuse to ship a libdrmtap built without the EGL backend libdrmtap treats egl/glesv2 as OPTIONAL: without their headers and pkg-config files meson silently builds a CPU-only stub. The stub still exports every symbol the loader gates on, so nothing downstream notices, and the split capture depends entirely on the unprivileged side EGL-detiling the scanout it receives. The result is a build where DRM capture quietly degrades to PipeWire on every tiled-scanout host, which is most of them. Our CI asserts this on the .so it builds; a developer or packager running build.py got no such check. Assert on the artifact rather than passing -Degl=enabled: that option only exists in libdrmtap past the pinned 0.4.15, and checking what was actually produced also catches a stale or substituted object, which a build flag cannot. Same two markers CI looks for, and for the same reason an ELF-level check does not work: EGL is reached by lazy dlopen so there is no DT_NEEDED. * drm: gate the libdrmtap ABI on the minor, and skip the warm probe on X11 Two items from the review that I had recorded as done and were not. The ABI check had a floor and no ceiling, so 0.5.0 and 0.9.9 passed. Under 0.x semver the minor is the breaking axis, and libdrmtap freezes only drmtap_device and drmtap_dmabuf_desc: drmtap_frame_info, drmtap_display, drmtap_config and drmtap_cursor_info are not frozen. A 0.5.0 adding one field to drmtap_frame_info still reports major 0, so we would have loaded it and read every field at the wrong offset, in the root service. It now requires the verified minor; a 0.5.x needs a deliberate bump after comparing the layouts. The unit test asserted the opposite of this, in as many words ("0.5.0 must pass"), so it was holding the hazard in place. Replaced. warm_availability ran on X11 too, where every consumer of the verdict sits behind an !is_x11() check, so the root service opened DRM readers for a path the session can never use. * drm: close the full-review findings (a third latched flag, and two escapees) The one that matters: the display-cache refresh worker was the THIRD copy of the wedged-flag hazard. catch_unwind covered only the enumeration, and thread::spawn panics on EAGAIN after RUNNING was already swapped true, so either path parked the flag for the process lifetime and every later refresh - including every udev hotplug - returned early forever. Same ownership guard as UINPUT_REFRESH_BUSY (the flag is handed back and re-taken mid-loop, so an unconditional RAII release would clear a replacement worker's flag), plus a fallible spawn whose failure drops the closure and releases the slot. DRM_PROBE_IN_FLIGHT, UINPUT_REFRESH_BUSY, now this: the lesson stays 'grep for every site with the shape', and twice was not enough. Two findings had been flagged in an earlier round and escaped the ledger: - an unrecognized convert-output fourcc fell through to 'present as BGRA' with a debug log, where every sibling validation in that function is a hard error that lets the caller fall back to PipeWire. A 64bpp output passes the stride check and encodes garbage. Hard error now. - the trust-boundary validation constants (fourccs, MAX_DIM, MAX_FRAME_BYTES) were declared independently on both sides of the split. Hoisted into drm_reader, imported by the converter, so the two halves cannot drift apart about what data they will touch. The rest: - the CI symbol extraction dropped any loader symbol containing a digit and degraded to a pass-with-zero-iterations no-op if the b"..." literals were ever refactored; digits allowed, count asserted, notice de-hardcoded. - 'drm' in features was a substring test on the comma-joined string, so a future drm-lease feature would have shipped the consent-bypass deb without --drm. Exact membership now. - the security doc claimed the deb is built on an ubuntu18.04 container; the only deb job runs on ubuntu-24.04. The 18.04 sentence now says what is true: 2.4.95 is an API floor, the binary floor is the build host's. - DRM_DISPLAY_CACHE poison handling was recover-in-the-writer, panic-in-the-readers; both readers now recover like the writer. - the producer prewarm ran on X11 where no consumer can connect, the same inconsistency just fixed for warm_availability. The listener still starts (the service outlives sessions; a later Wayland login must find the socket), only the prewarm is skipped. * drm: measure the verification deb glibc floor and put it in the artifact name The workflow already said in a comment that this deb is a verification build with a higher glibc floor than the release debs, because it builds on the runner rather than in the ubuntu18.04 container the stock job uses. A comment in this file is not visible to whoever downloads the artifact from the Actions UI, and the name was a bare rustdesk-unattended-wayland-x86_64.deb, so it read like something installable anywhere. The floor is now read off the built object with objdump and goes into the artifact name, so the constraint travels with the file. Measured rather than stated: a hardcoded number would drift the next time the runner image moves. Verified the pipeline against a real deb here (2.39). Restoring the container build is the other option and is cheap to do -- the recipe including the two 18.04 traps is still in this repo's history -- but it belongs with a deb that is actually distributed, not with a job whose contents are already asserted in-place. * drm: the same latched-flag bug a fourth time, in my own fix for the third I built UinputRefreshGuard INSIDE the spawned closure, so it only covered paths where the closure ran. thread::spawn panics on EAGAIN after the swap, so no guard existed and the flag stayed set for the process lifetime, which is the exact failure the guard was introduced to prevent. I then wrote RefreshSlot correctly - constructed before the spawn, moved in - two hours later and did not go back to fix its sibling. Both are right now, and the spawn is fallible in both. Also from the review: - DRMTAP_PREBUILT_DIR returned before the EGL-stub assertion, so the check only guarded the source build. That is backwards: prebuilt-dir is the widest override (no fetch, no sha check, an object this script never sees), the likeliest to hand over a stub, and the path our aarch64 cross-build actually uses. Verified the assertion accepts a real .so and rejects one built with -Degl=disabled. - convert() bounded only the frame libdrmtap returns, not the descriptor going in. offsets/pitches address plane ranges inside the dma-buf, so those are what a malformed pair would reach past. Bounded per populated plane, the same way the export side is. Defense in depth (the producer is root-authenticated and libdrmtap validates against the fd since 0.4.12), but the two halves should agree before the C sees the data, not after. - the flutter patch step used '[[ test ]] && git apply' as its last command, so the step would FAIL rather than skip the first time FLUTTER_VERSION moves off 3.24.5. Explicit if/else, and the values now come from the environment instead of ${{ }} interpolation, which also clears zizmor's template-injection warning. Checked both branches. Declined: the cursor id/cache-key convergence finding. Both accessors use one selection over one map, so they can only disagree across a publish race, and state.hcursor is already set to the id ACTUALLY served (drm_served_id), which is the sync the finding asks for - added in an earlier round. * drm: stop routing gates from paying for the availability probe A Major finding I skipped twice, and the file already argued against itself: wayland.rs's own NOTE says re-probing _drm from the async enumeration path blocks the executor long enough to trip 'deadline has elapsed' and spiral into a restart loop -- and then six routing gates called is_available(), which runs query_displays() inline whenever the state is Unknown (cold start, or a NEGATIVE_TTL expiry mid-session). ensure_inited, is_inited, get_displays_and_primary and clear() are exactly the paths the NOTE names. is_available_cached() is a single mutex read: KNOWN-available or not. The six gates use it, which is safe because they are routing decisions, not capability ones -- a cold cache answers 'not DRM' and the caller takes the PipeWire path it would have taken anyway. Switching all seven, which is what the finding literally suggested, would have introduced a worse bug: warm_availability calls query_displays() directly, so is_available() would have had ZERO callers and nothing would ever probe lazily again. A --server that started before the root service would then never see DRM for the rest of its life. get_capturer_for_display keeps the probing form -- it is sync, on the plain video thread, it is the capture-build path where a definitive answer is the point, and it is what makes a cold cache recoverable. * drm: stop leaking the authorized _drm fd into forked children libc::dup() does not copy the close-on-exec flag, so the dup'd _drm socket fd was inherited by every child this process forks. This process is the ROOT service and it does fork synchronously elsewhere (the loginctl active-uid lookup), and that fd is an ALREADY-AUTHORIZED channel to the one thing on the box that hands out scanout dma-bufs. F_DUPFD_CLOEXEC instead. Measured the difference rather than assuming it: dup() leaves FD_CLOEXEC clear, F_DUPFD_CLOEXEC sets it. Also the last two artifact sources without the stub check: - --package + --drm stages the .so straight out of a bundle somebody else produced, with no _assert_so_has_egl. Third source, same exposure as DRMTAP_PREBUILT_DIR, now asserted like the other two. All three artifact paths are covered. - the workflow triggers omitted src/server.rs, src/server/input_service.rs and src/platform/linux.rs, which all carry DRM wiring (warm_availability, the cursor path in run_cursor, the producer start and get_cursor/get_cursor_data), so a PR touching only those skipped the entire drm verification. Added to BOTH mirrored lists and asserted equal (15 == 15). * drm: decide x11 inside the prewarm, with a bounded re-check the one-shot is_x11() gate at the call site misfired during boot: get_display_server() falls back to "x11" while loginctl cannot name the seat0 session yet, so on a wayland host with the service enabled at boot the prewarm was skipped for the life of the service and only ever ran after a manual restart, which is how every deploy happened to exercise it. move the gate inside drm_prewarm and re-ask every 2s for up to 30s. a genuine x11 or headless host exhausts the budget having opened no DrmReader and no drm fd; a wayland boot proceeds as soon as the session reads as wayland. measured on a boot: the skip used to fire 0.8s in while loginctl reported the wayland greeter in that same second, and graphical-session.target only arrived at +5s. * drm: wake idle-disabled displays and settle the topology before the client is promised a list a compositor that idles long enough does not merely blank a panel: it disables the connector, leaving no scanout for any capture backend to read - not drm, not pipewire, not x11. on an unattended box that meant connecting to whatever was still scanning out (on an apple t2, the 60x2170 touch bar strip) with the real panel sitting disabled next to it, or a stale cached list advertising a display with nothing behind it ("waiting for image"). the fix has three parts, and where the wake runs is the load-bearing one: - the root service answers every _drm handshake with a fresh, settled enumeration (drm_enumerate_settled): enumerate, and if a CONNECTED display has no crtc, inject one synthetic 1px pointer round trip over uinput (rate limited to one per 20s, one winner via compare_exchange) and hold the answer until nothing wakeable is left undriven or a 3s deadline passes. rate-limited losers wait for the outcome too while a wake is recent - answering with the pre-wake list is exactly the mid-transition state that produced duplicate, misindexed monitors. connectors a wake could not bring back are latched by connector identity (device:connector) and the latch is self-refuting: an entry later seen scanning out is dropped, so one slow modeset cannot disable the wake for the life of the service, and a dummy plug cannot suppress the wake for a different panel that idles later. - the login path refreshes the cached display list over a live handshake (refresh_displays_for_login) before peer info is built, so the list the client is promised is the post-wake truth and never changes under it seconds later. the publish is generation-checked against concurrent writers; every failure mode keeps the previous cache, so a login can never get harder than before, only truer. - the capture handshake resolves the display index the client chose by connector identity against the handshake list (the service enumerates fresh per connection, so an index alone is only meaningful against the list it came from), fails the build cleanly when that monitor is gone, and no longer republishes its handshake list into the availability cache - that unordered write could clobber a newer settled list with pre-wake data and re-advertise a reordered list under a live session. the display-list read timeout grows to cover the settle budget (DISPLAY_LIST_TIMEOUT_MS), or a wake that needs the full recheck would turn into a spurious handshake timeout on exactly the host it exists for. removing the display cache from the handshake path also retires DRM_CACHE_WARMED; the cache still feeds the topology push and the udev listener. measured on the t2 (amdgpu panel idle-disabled, appletbdrm touch bar still scanning out): connect -> wake fires with undriven=1 -> panel returns in ~330ms -> the same probe answers 2 displays -> the client starts on the panel. with the panel awake: zero wakes. the root service still never maps libEGL/libGLESv2. * drm: close the round-7 review findings - the renumbering probe in the DrmDisplaysChanged handler now reads the pushed list at wire_idx, the slot our monitor held in the service's index space, instead of at the index the client chose. the pushed list shares the handshake list's construction, so probing the client index compared two different index spaces whenever a wake or hotplug had renumbered entries - tearing down a healthy stream or missing a real renumbering. - both message-body reads (cpu frame, cursor pixels) now run under a deadline. only the header read re-checked `stop`, so a producer dying between a header and its body pinned the receive thread forever and every rebuild leaked a thread plus its render context. - the drm cursor cache gets a size ceiling (drm ids are derived from the shape's content, so an animated pointer minted a new key per shape and the map grew for the life of the service; x11 ids come from a small serial set, so the ceiling is gated and the stock build is untouched). - has_non_drm_backed_display reads a two-scalar accessor instead of cloning and geometry-augmenting the whole display list on every cursor tick. - the libdrmtap pin validation moved out of import time into build_libdrmtap_so(), so leftover DRMTAP_* environment variables or a malformed sha cannot fail a stock build that never touches libdrmtap. - reworded a workflow comment whose literal expression marker broke actionlint. * drm: close the round-8 review findings - the .so contract check in the drm workflow runs under strict mode: without set -e the trailing ::notice echo returned 0 and masked the `test "$missing" -eq 0` assertion, so the step passed even with a missing loader symbol or a CPU-only stub. the two extraction pipelines get an explicit rescue so a zero-match grep still reaches the ::error guard that explains WHY instead of dying silently. - the pipewire-fallback geometry guard no longer compares the physical drm size against the portal rect on a single-display host: the rect is the compositor's LOGICAL size, so on a scaled output the two legitimately disagree (2880x1800 vs 1440x900) and the guard rejected the one valid fallback, restart-looping the display instead of degrading. on a single-display host the whole-desktop stream is that display by construction, so only the position has to agree; the size check stays on multi-monitor hosts, where it is what tells one connector apart from the full-desktop rect. * drm: close the round-9 review findings - strict mode on the remaining two assert steps of the drm workflow (the deb-contents assert and the glibc-floor measurement): same masking pattern as the .so contract step fixed last round - without set -e only the last command's status counts and the mid-script checks were decorative. the floor extraction gets an explicit rescue so a no-match grep still reaches the `test -n` reporter. - the security doc states the whole accepted version window (exactly the pinned minor with a patch floor; a NEWER minor is refused too, because the mirrored struct layouts are only verified against the pinned one), and the auditing section carries the command matching its leftover-object comment. - the uinput-missing warning literal lost the embedded space runs a reflow had left in it (it is the sole, once-per-process diagnostic for that failure and it read as a run-on line with gaps). - the geometry-mismatch path in frame() hands the taken buffer back to the recycler before erroring; dropping it made every rebuild cycle re-allocate a scanout-sized buffer. * drm: document the display wake in the threat model the wake is deliberate input injection by privileged code, which is exactly the kind of thing this document exists to state precisely rather than leave to be discovered in the diff: why it must run in the root service (uinput is root-only and the compositor holds drm master), what it can reach (only an already-authorized _drm connection triggers it), how narrow the trigger is (a connected-but-undriven connector, with a self-refuting per-connector memory for the hopeless ones), the rate bound (one wake per 20s process-wide, single winner), the device lifetime (created and destroyed around the emit), and that a host without /dev/uinput loses nothing it had (such a session was already view-only). * drm: close the round-10 review findings - the /dev/dri gate returns the CANONICAL path instead of a bool, and both callers open that value. answering yes/no meant the caller handed the original string to libdrmtap, which re-resolved every symlink component after the check - a check-then-use window, in the root service. this is the whole point of the gate, so it should never have been able to hand back an unresolved path. - `--package --drm` builds the capture library instead of demanding it inside the bundle. no build path puts libdrmtap in a bundle folder (the flutter deb builds it straight into the staged deb), so that check made the flag combination impossible to satisfy. the safety property it stood in for is now asserted directly and better: the staged BINARY must carry the drm dlopen path, so a stock binary can never be packaged under the consent-bypass name. a bundle that does carry a .so keeps its existing EGL assertion, and the variant naming keys on the explicit request rather than on what happened to be staged. - the deb assert step globs into an array and asserts the count: under set -e `ls` aborted before its own `test -n` could report, and several matches produced a multi-line value whose mv failed with an unrelated error. * drm: finish the logical-geometry comparison, and chain a re-raise the pipewire-fallback guard now normalizes BOTH sides to logical before comparing. last round fixed only the single-display case, which left the same defect on the shape that actually has it: on a multi-monitor scaled host the advertised geometry carries the PHYSICAL drm mode plus the compositor scale, while the portal rect is already logical, so a scaled output disagreed with itself (2880x1800 against 1440x900) and a per-connector stream that really was that display was rejected, leaving it advertised offline instead of degrading. the size check itself stays: on a multi-monitor host it is what tells one connector apart from the whole-desktop rect. the failure message reports the logical numbers, the ones actually compared. also chains the libdrmtap read failure with `from err` so the original OSError survives (ruff B904). * drm: fix two review-suggested changes that were wrong, and stop overclaiming in the docs an adversarial sweep over the whole batch, aimed at the failure that kept recurring here (a hazard identified and only some instances fixed), found that two changes made on review advice were themselves defects. both are reverted with the trace written down so they do not get "fixed" again: - the hotplug renumbering probe reads the pushed list at the CLIENT index again, not the service one. `bound_to` is an IDENTITY, (device, crtc_id), so comparing it against a slot is not a cross-index-space comparison; and `swap_available_displays` installs that same list as DRM_STATE two lines later, which IS the client space - display_service re-advertises it, input is mapped through it, the next rebuild reads `expected` out of it. Probing the service index answered a question nothing downstream consumes and went quiet in exactly the case the guard exists for: a stream whose wire_idx differs from its client index kept running while that index came to mean another monitor, so the client rendered monitor A believing it was monitor B and routed every click accordingly. - the pipewire-fallback guard compares raw sizes again. BOTH sides are physical: `Display::width()` on the wayland variant returns `physical_width()`, and `try_fix_logical_size` only repairs the capturable's separate logical_size field. Scaling the drm side therefore compared logical against physical and rejected the valid stream on precisely the scaled outputs it was meant to rescue. The single-display carve-out now needs BOTH sides to be single, since a monitor on a card the service cannot open is missing from the drm list while the compositor still drives it. also from the sweep: - a capture build whose index is out of range of the advertised list now fails instead of falling back to the raw index, which the wake can have grown the service list back past - that bound a second video service to a monitor already being served and recorded its health under the wrong identity. - the security doc no longer claims the privileged process never loads GL. That is true of the DEFAULT path and measured there, but the CPU fallback converts in-process, and a tiled scanout can only be decoded through the GPU, so libdrmtap dlopens libEGL in the calling process when the frame needs it. The doc now says which property belongs to the path and which to the process, and bounds the cases instead of overclaiming. - the wake latch is described honestly: it self-clears when the display is next driven by anything, but nothing retries it, so a transient failure can leave it latched on an unattended host. - the wake's uinput device DECLARES two axes and BTN_LEFT (libinput ignores a device that does not look like a mouse) while EMITTING only the net-zero axis round trip. the doc said one axis and no keys, describing the emit as if it were the declaration. - the drm CI never ran for a change to the root Cargo.toml, where the top-level `drm` feature is defined, or to Cargo.lock, which every `--locked` build here resolves against. both triggers list them now. - the deb assertion checks the packaged BINARY carries the libdrmtap dlopen path, not just that the library was staged beside it. * drm: close the round-13 review findings - the ABI refusal message has a branch for an unverified MINOR. It had only two, so a library NEWER than the pinned minor was told it "predates the split-capture API" - the opposite of its problem, and the kind of message that sends someone looking in the wrong place. the warn line names the accepted minor too. - the libdrm floor no longer claims 18.04 ships 2.4.101: base bionic shipped 2.4.91, which is BELOW the 2.4.95 the GetFB2 API needs, and only the updates/HWE stack clears it. read as "18.04 with updates, or newer". - the drm-build marker scan reads the staged binaries chunked inside a `with`, overlapping by len(marker)-1 so a marker cannot fall across a chunk boundary, instead of pulling a 45 MB librustdesk.so into memory and leaning on refcounting to close the file. verified against a real drm build (found) and an unrelated binary (not found). * drm: close the round-14 review findings - the .so contract and deb assertions no longer pipe into grep. under `set -o pipefail`, `producer | grep -q` reports a FALSE FAILURE once the producer outruns the 64 KB pipe buffer: grep -q exits at the first match, the producer dies on SIGPIPE, and pipefail makes that the pipeline's status - so a library that HAS the symbol is reported as missing it and the step fails on a good build. measured on a real EGL-enabled .so (101 KB of strings, both markers present): the piped form reported both missing. this was introduced by the strictness fix two rounds ago and only passes today because a release-sized .so fits in the buffer. NOTE the obvious repair does not work either - materializing the output and piping the variable keeps the pipe and fails identically (measured), so these now match with bash's own pattern operator and no subprocess at all. verified with positive and negative controls. - warm_availability decides X11 for itself, inside its retry loop, with the UNMEMOISED `scrap::is_x11()`. this is the same one-shot-at -startup bug the pre-warm had, in its sibling call site, left behind when that one was fixed: the check ran during startup, where loginctl cannot yet name the seat0 session and the answer defaults to "x11", so a Wayland host that came up slowly skipped the warm for the life of the process and got back the cold-probe "No displays" symptom the warm exists to remove. the memoised form would have moved the bug rather than fixed it, since it latches its first answer. - the grab_desc SAFETY comment says what the frame protocol actually is instead of promising a release on every return path: traced in the C, a failing grab_desc leaves nothing to release (-EINVAL returns before allocating, a failed inner grab has already cleaned up, and -ENOTSUP releases the frame itself), so releasing on those paths would be a double free. * drm: bound the work an unauthenticated peer can make the root service do the `_drm` socket is world-connectable by design (the unprivileged --server has to reach it), and every accepted peer got a spawn_blocking authorization - which forks `loginctl` whenever the active-uid cache misses - BEFORE any admission bound applied. MAX_DRM_CONNS does not help there: it only counts peers that already passed. So a local uid that will be rejected could still open connections in a loop and keep the shared blocking pool busy, and that pool is shared by every live capture stream, which is exactly the stall the comment above the authorization warns about. add a separate, small in-flight bound around the authorization step, deliberately NOT the same counter as MAX_DRM_CONNS: sharing one would let a rejected flood eat the capacity the real consumer needs. the guard is taken before the spawn and released as soon as the verdict is in, so the slot covers the authorization only. the rejection logs at debug rather than warn for the same reason the existing rejection is silent - anything reachable by any local uid must not be an unbounded log-write primitive. unit-tested like its sibling, including that the pre-auth bound stays the tighter of the two. * drm: reject an out-of-range num_planes on the import side instead of clamping it the incoming descriptor's plane count was clamped to 1..=4 for the validation loop but passed to libdrmtap RAW, so a wire descriptor claiming 7 planes was checked as if it had 4 and then handed over claiming 7. the pinned libdrmtap refuses >4 itself, so this was not an overflow today - but the stated purpose of that block is that the two halves of the split agree about what they will touch BEFORE the C sees it, and that only holds if the count travelling with the descriptor is the count this side bounded. it also stops this half depending on an internal check in a library pinned from another repo. reject and normalize instead, which is what the EXPORT half already does in grab_desc; the two sides now have the same shape. * drm: close the round-17 review findings - the scanout dma-buf fd is duplicated with F_DUPFD_CLOEXEC. `dup(2)` never copies close-on-exec, so this fd was inherited by every child the ROOT service forks (it forks synchronously for the loginctl active-uid lookup) - and what this fd names is the live screen contents. this is the SAME defect already closed on the `_drm` socket fd in ipc/drm.rs; fixing that one and not grepping for the siblings is how this survived. there is exactly one dup in the drm path now and it is this one, verified by grep. measured that F_DUPFD_CLOEXEC sets FD_CLOEXEC and preserves the O_RDONLY access mode the read-only export depends on; SCM_RIGHTS delivery is unaffected since the receiver gets its own descriptor. - Desktop::refresh resolves HOME on the login-Wayland path too, since the drm build now starts a --server as the greeter uid there and a child with no HOME has nowhere to put its config. the compositor variables stay blank deliberately: the drm path talks to the root service and a render node, never to the compositor or the portal, which is why it works at a login screen at all. reasoned, not measured: a current GDM runs its greeter as `gdm-greeter`, which `is_gdm_user` does not match, so that path is not reachable on our hardware - measured there, the greeter server gets a fully populated environment through the branch below. - the glibc-floor step globs into an array and asserts the count, like its sibling assert step. that sibling was fixed two rounds ago and this one was left behind. * drm: put the display wake behind its own compile gate and a runtime option everything else in this backend READS: it captures a scanout. the wake WRITES, injecting one synthetic pointer event from the root service into the user's session. that is a different kind of operation and it should be switchable on its own, at both levels. - compile: a `drm-wake` feature on top of `drm`. every wake-only item is gated and drm_enumerate_settled has two definitions, so `--features drm` builds the same capture path with no wake code in the binary. verified on a RELEASE artifact with both controls: the drm markers are present (Started drm ipc server) and the wake string is gone. the unattended deb passes drm-wake, so answering an objection is one word in build.py rather than a revert. - runtime: `enable-drm-display-wake`, server-side, the same shape rustdesk already uses for the closest thing it does to this (keep-awake-during-incoming-sessions, which PREVENTS sleep where this RECOVERS from it, and is acquired only once a connection exists, which is too late for a host that cannot be reached). the `enable-` prefix is load-bearing: option2bool reads an absent value as ON, and a host whose screen went dark is the case the unattended package exists for. set it to "N" and the service stays read-only with respect to input. the key is declared in this file rather than in hbb_common's `keys` module, where rustdesk's own option constants live: hbb_common is a submodule of a repo we do not control, so a constant there could only land after an upstream change plus a submodule bump. the option system reads by string, so registration is not required; the cost is that the key is set in the config file rather than the settings UI, which is how an unattended host is configured anyway. * drm: enumerate /dev/dri by path instead of trusting one auto-detected card when `list_devices` gives us nothing to work with, the fallback was a single auto-detected reader. that is the wrong unit of enumeration on a multi-card host, and the reason is worth keeping: libdrmtap's auto-detect picks a card that is SCANNING OUT, so when the interesting display is asleep it picks a DIFFERENT card and we enumerate only that one. the asleep display is then invisible - not as a display, and not as an undriven connector either, which is what the wake keys on. measured on the t2 with the panel idle-disabled, through a direct libdrmtap call: auto-detect succeeds and binds card0, the touch bar, because the touch bar is what is still scanning out; the 2880x1800 panel on card2 is invisible to that reader, while opening card2 by explicit path in the same instant reports `eDP-1 crtc=0 active=0` exactly as needed. so walk /dev/dri/card* and ask each, with auto-detect demoted to a last resort for the case where no card opens by path. this path is reached only when list_devices is unavailable (a pre-0.4.15 .so) or opened nothing, so it costs nothing on the normal path - it is defensive, not a fix for anything observed with the pinned library. the enumeration result is logged UNCONDITIONALLY, including the empty case, because a silent "found nothing" gives no way to tell an empty host from a failed enumeration. * docs: state the per-frame reauthz and the wake's one-shot bound Two things the security doc left implicit, both measured on 2026-07-31. The `_drm` authorization is described as per-connection, which undersells it. DRM/KMS capture is not session-scoped - it grabs the physical scanout of a CRTC no matter which session owns the display - so the check is re-run on every frame, and when a user logs in at a greeter the greeter's stream is closed rather than continued. That is the property that stops an outgoing greeter process from capturing the screen of the user who just logged in, and it is worth stating where a reader is looking for exactly that confinement. And the wake section never said what happens after the wake. It resets the compositor's idle timer; it does not hold the display on. Left alone, the connector idles off again one full idle period later: 30.3 s at a GDM greeter, 70.3 s in a user session with idle-delay=60. Saying so makes the existing "useless as a way to keep a screen lit" clause concrete, and points at the component whose job that actually is. * drm: ship the wake in the CI deb, and assert the artifact on both package paths Three findings from the round on the wake-gate commits, all the same shape: the gate made "what was asked for" and "what was produced" diverge, and two places still trusted the first. CI built the unattended-wayland deb with `--features ...,drm` and then packaged it with `--skip-cargo`. build.py appends `drm-wake` for `--drm`, but skipping cargo means whatever that explicit line compiled is what ships, so the deb had no wake code in it at all while being named and documented as the variant that has it. The feature list has to be complete on the line that actually builds. The marker assertion that catches exactly this class only guarded one of the two packaging paths. `build_deb_from_folder` asserts that the staged binary carries the libdrmtap dlopen path before it takes the unattended-wayland name; the flutter path did not, and `--skip-cargo` reaches that one. A stock binary could therefore be packaged under a name that conflicts with and replaces the stock package, and then never capture. Hoisted the check to module level and called it from both, before the bundle is renamed. And the security doc described the synthetic input injection as an unconditional property of a drm build. It is behind its own compile feature and a runtime option, which is exactly what an operator auditing the deb needs to know. * drm: stop a delivered frame from erasing the two verdicts it says nothing about A deep review pass over the whole branch, run because a maintainer once found two bugs here that nineteen rounds of an automated reviewer had missed. Three findings, two of them the same root cause, all confirmed by re-reading the code. The first frame of a session dropped the display's whole health entry. That is right for the zero-frame streak, which is exactly the verdict a delivered frame refutes, and wrong for the other two: - `last_build`/`rapid_builds` exist for a display that delivers a first frame and then fails downstream every cycle. Wiping the cadence on that frame meant the flap guard could never reach RAPID_REBUILD_MAX in the one case its own doc comment describes. It was a guard that could not fire. - `prefer_cpu` records which GPU exports a monitor, a property of the host, and is documented as following the monitor for the process run. Erasing it on the first frame it made possible meant every rebuild re-paid a dead dma-buf session: fail, learn, take the CPU path, forget, fail again. It never demotes, because the CPU session clears the streak each time, so it repeats for the process lifetime. Worse, the bit is set on the recv thread and was deleted on the encoder thread, so a convert failure racing a queued frame could destroy it inside the very session that learned it. So reset only the streak. Only a topology change, where the GPU mapping really can have changed, may still clear the convert verdict. Second, `get_primary_index` was a second, weaker copy of the connector-to-output matcher: name-only, with neither the unique-resolution step nor the layout-order fallback the augmentation grew. On a compositor whose names do not normalize to the DRM names it answered 0 while the geometry augmentation had matched that display to a different output, so the advertised primary and the advertised geometry disagreed. It now asks the same assignment, which makes them agree by construction. Third, packaging asserted half of what the deb claims. `assert_staged_binary_is_drm` looked for the libdrmtap dlopen path, which `--features drm` alone also carries, so a bundle built without `drm-wake` could still be named and documented as the variant that wakes an idle-disabled display; it now requires the wake marker too. And nothing anywhere checked that the libdrmtap being shipped is one the runtime would accept: `abi_accepted` is the only validation of the pinned version and it runs at dlopen time on the user's machine, so the pin and the gate could drift and every existing assertion would still pass -- EGL markers say nothing about the version, the CI symbol contract never calls drmtap_version(), and the deb regex matches any version. Staging now applies the gate parsed out of the Rust, so a green build cannot produce a deb whose capture can never start. * drm: fix the ABI cross-check's path, and stop panicking on a failed spawn The ABI cross-check added in the previous commit could never run: both callers of stage_libdrmtap_into_deb chdir into flutter/ first, and the check opened drmtap_dl.rs by a path relative to the cwd, so every --drm packaging run died with FileNotFoundError. CI caught it. It is anchored on __file__ now, and read through a context manager. Worth naming why the test missed it: the check was exercised from the repository root, which is the one directory where the bug is invisible. A control that does not reproduce the call site's conditions is not a control. Three more, all the same class the previous commit was already fixing - a hazard closed at one site and left at its siblings: - `std::thread::spawn` panics when the thread cannot be created, and the panic unwinds into whoever called it. The two hardened workers used Builder; the five remaining DRM threads did not. The startup ones now log and degrade (a lost pre-warm costs one cold probe, a lost udev listener costs the mid-session push, a lost warm costs the first session), and the two per-session ones live in functions that already return ResultType, so they fail that one connection cleanly instead of unwinding through the handler. - The wire descriptor's `num_planes` was clamped to 1..=4 here while `drm_render::convert` rejects an out-of-range count on purpose, so that the count the C reads is the count this side validated. Clamping made that reject unreachable: a descriptor claiming 7 planes arrived as 4 and passed. The two guards were added by different review rounds and had been quietly cancelling each other. The raw value is passed through now, leaving one validation site, next to the code that dereferences it. - A SAFETY comment claimed the cursor is released only on success. It is released on every path after a successful get_cursor; only a failed get_cursor returns without releasing, because then there is nothing to release. The release protocol is the reason that block is unsafe, so the comment describing it has to be right. * drm: convert the last panicking spawn, and resolve geometry outside the lock The spawn conversion in the previous commit missed one. `query_displays` still used `std::thread::spawn`, which panics when a thread cannot be created, and it is reached from both `get_capturer_info` and `warm_availability` - so the panic would land on the capture-build path rather than being reported as the failed probe every caller already handles. There are now none left in the two DRM files. Worth writing down how it survived a pass whose whole purpose was to find it: the previous commit enumerated the siblings with a grep piped through `head`, there were eleven matches, and `head` printed ten. The one it cut is the one that was missed. Same shape as a build log read through `tail` and a `find` given `-xdev`: the tool truncated the survey and the survey looked complete. When enumerating sites for a class fix, do not pipe the enumeration. Also, `get_capturer_for_display` resolved the advertised DRM geometry while holding the `CAP_DISPLAY_INFO` read guard. That lookup runs a compositor output roundtrip, and `clear()` takes the write guard on every capturer teardown - which is what is happening when a display is demoted or flapping, i.e. exactly when this path runs. The value does not depend on anything inside the guard, so it is resolved before taking it. And the security doc listed the unattended package's `Conflicts`/`Replaces` but not its `Provides: rustdesk`, which is the field that lets a third-party package depending on `rustdesk` be satisfied by the consent-free variant. An operator auditing that metadata needs all three. * drm: test that a delivered frame keeps the cadence and the convert verdict The guard this locks in could never fire before: a delivered frame dropped the whole DisplayHealth entry, which took last_build/rapid_builds with it, and those exist precisely for a display that delivers a first frame and then fails downstream every cycle. prefer_cpu went the same way, erased by the first frame it had made possible. The test drives the real frame() path through the existing harness rather than simulating the bookkeeping, and it was checked against the old behaviour: with the entry removed again it fails on "the entry must SURVIVE a delivered frame". A test that has not been seen failing is not evidence. * drm: bound the two waits a peer could hold open in the root service A review pass over the privileged side, reading src/ipc/drm.rs as a local unprivileged attacker. Two findings, both confirmed by tracing every link. The wire had a deadline in one direction only. Every read has been bounded since the beginning, and next_raw_into even carries the argument for it: a peer that writes a header and then stops pins the other end forever on a readiness wait. The write side had no deadline at all. That asymmetry costs more here, because the parked task is in the root service: a peer that simply stops reading - a kill -STOP on its own --server, a ptrace stop, a frozen cgroup - leaves the send blocked inside the forward loop, so the loop top is never reached again. The credit stall, the per-frame reauthorization and the topology-generation check all live at that loop top, and the connection slot, the worker thread and its DRM context stay pinned until the peer chooses to resume. drm_write_all is the single funnel for both directions, so one deadline there covers every send; the consumer's frame-ack write had the same shape and gets the same bound. And drain_frame_acks looped until WouldBlock, which is a promise the peer gets to keep. It is synchronous on the single-threaded _drm runtime, so a peer that writes a continuous stream instead of one ack byte per frame keeps the receive queue non-empty, never yields, and pins that thread at 100% CPU - starving every other stream on it, which on a multi-monitor client means one connection wedging its own siblings. Capped per call, with an early return once the credit budget is full; anything left stays queued for the next pass. Three comments were describing a mechanism that no longer exists. Two still said a delivered frame drops the whole health entry, which stopped being true when that was narrowed to zeroing the streak; the third, written in that same change, pointed at drm_clear_prefer_cpu, a function deleted several commits earlier. The convert verdict having no clearing site is correct and now says why: it is keyed by connector identity, so a monitor that moves to another GPU arrives under a new key and starts clean. Also, the new regression test held the process-wide health mutex across its assertions, so the one failure it exists to report would have poisoned that mutex and buried itself under unrelated PoisonErrors in its sibling tests. It copies the record out and releases the guard first, as the module's own helper does. * drm: clear the stale _drm entry by fd, and fix three comments that argue backwards new_drm_listener cleared the stale socket with std::fs::remove_file, which is unlink(2). Against a directory-typed squatter that returns EISDIR and leaves the entry in place, and endpoint.incoming() then fails EADDRINUSE, so DRM capture falls back to the portal for the rest of the boot over an entry we could have removed. The _service listener has never had that hole: it removes entries through a no-follow fd on the parent directory, fstatting the entry first and choosing AT_REMOVEDIR when it needs to. That helper now takes a path instead of a postfix, so the _drm listener - which deliberately stays outside hbb_common's postfix machinery - can use the same one on the directory it just hardened. The precondition is narrow (an unprivileged process has to win the creation race before the root service first hardens the dir on a fresh boot), which is why the failure is a warn and not a bail. Three comments stated their reason backwards or more strongly than the code supports. None of them changes behaviour; all three would send the next reader to verify the wrong thing. The wake's 20 s rate limit was justified as being short enough to be useless as a way to keep a screen lit. That is inverted: a shorter gap would make relighting easier, not harder, and 20 s is below every idle period we have measured (30.3 s at a greeter, 70.3 s in a session). What actually bounds it is that the wake is one-shot, which the next sentence of the same doc already says. Fixed at both sites, the constant and the security doc. The doc block above drm_enumerate_settled reads as one paragraph but spans a cfg split, so its shared contract and the wake-less specialisation looked like one statement about the arm below it. Marked explicitly. And get_primary_index claimed its answer agrees with the advertised geometry by construction, which is true only where augment_with_wayland_geometry runs the same assignment - it declines below two connectors or two outputs, and in that band the two functions run different code. The answer is still never worse than the documented fallback there, and now the comment says which. * drm: test that the fd-based removal clears a directory squatter The regression this pins is the one the previous commit fixed: a stale entry in the IPC parent directory is not necessarily a socket, and unlink(2) refuses a directory. The test asserts remove_file fails on it FIRST, so a passing run cannot be vacuous, and it checks the second call succeeds too, since this runs before every bind. Confirmed red against a neutralised helper before being kept. * drm: fix what the previous commit's own comments got wrong A review pass over 08d311d60 - the commit whose stated job was correcting three comments that argued backwards - found that four of its replacements were wrong in turn. Two independent passes agreed on each. This is the correction. The 20 s gap paragraph was never attached to the constant. It is the first paragraph of a doc block that runs on to OPTION_ENABLE_DRM_DISPLAY_WAKE, so it documented a config-key string, while DRM_WAKE_MIN_GAP three lines below had no doc at all. That misplacement predates the previous commit; expanding the paragraph from one line to five without noticing does not. Moved onto the constant. Its content was also wrong for the second time. Saying the limit is not what stops a screen being held on was right; naming the one-shot property as the thing that does bound it was not. One-shot cannot bound a repeated relight when the permitted repeat interval is shorter than the idle period, which is exactly what the sentence before it establishes: 20 s against a measured 30 s. An authorized peer that keeps reconnecting can have the panel relit shortly after each idle-off, and what makes that acceptable is the authorization itself - root or the active session's own uid, who can hold their screen on with systemd-inhibit and need nothing from us. Both the constant and the security doc now say that, and the constant carries a note not to write the old claim a third time. The shared contract of drm_enumerate_settled sat on the arm the shipped build compiles out. build.py --drm adds drm-wake, so a maintainer opening the real function found it undocumented while a doc comment marked "shared" hung off its dead twin. A doc comment cannot attach to two cfg arms, so the shared part is now a plain comment above both and each arm keeps a short doc of its own. get_primary_index claimed a sole compositor output is matched to the lowest connector. It is not: pass 1 matches by normalised name and by unique resolution before any layout-order fallback, so the answer in that band can be any index. The conclusion survives - a name match is better evidence than a blind 0 - but the reason given for it was false, and the reason is what the next reader uses. And the directory case is narrower than it was written. AT_REMOVEDIR is rmdir, so what the previous commit closes is the EMPTY squatter; a non-empty one still returns ENOTEMPTY and still blocks the bind. Left that way on purpose - the cure would be root recursively deleting a tree an unprivileged process planted in a world-writable directory - and now stated at all three sites plus pinned by the test, which also stops claiming to cover the call site it does not reach. * drm: say less in these comments, since saying more keeps being wrong Third pass over the same comments, and the third set of errors in them. The pattern is not that any one sentence was careless, it is that every additional explanatory sentence is another falsifiable claim, and the ones that keep failing are the ones that reach past what the file can support. So this is mostly deletion: net fifteen lines fewer. The "SHARED CONTRACT" header was wrong about its own first paragraph. That paragraph describes waking, waiting and a rate-limit race, none of which the wake-less arm does - and the previous commit went further and pointed the wake-less arm's own doc at it, so that arm now claimed to do the thing the very next line said it does not. Only the second paragraph, on why an idle-disabled output is the trigger, is genuinely common to both. That stays above the pair as a plain comment; the wake behaviour moves onto the wake arm, where it is true. DRM_WAKE_MIN_GAP no longer argues about why unbounded relighting is acceptable. It named the wrong actor: the _drm peer is always our own unprivileged --server, while the party whose reconnects drive the relight is the remote client, which is neither root nor the local uid and cannot inhibit anything. The constant now states what it bounds and what it does not, and stops there. The security document makes the acceptability argument instead, and makes it about the right party: a peer already authorized to watch that screen gets it lit, which is visible to a person standing there, not additional access. Two narrower ones. The helper said a non-empty squatter yields a named error "instead of" EADDRINUSE; the caller gets both, and the sibling comment in the listener already said "ahead of", so the same commit disagreed with itself. And get_primary_index claimed the two functions disagree across the whole band where augmentation declines, which is false for zero outputs and for a single connector - it now names the one case that matters. Not touched, and pre-existing: MAX_DRM_CONNS's doc block has the same wrong-item defect (it opens on a function and ends on the cap), and drm_enumerate_all_displays runs two paragraphs together. Both predate this branch's comment work and neither belongs in a commit about it. * drm: give the send deadline one budget for the whole write, not one per wait The earlier commit put the timeout inside the loop, so the budget restarted on every iteration. A peer that accepts a byte just inside each window, or that keeps the socket flapping back to WouldBlock, re-arms it forever and the root task stays parked exactly as it did before - which is the stall the constant's own doc says it bounds. The diagnosis was right and the fix did not implement it. Both send paths now take one deadline before the loop and wait with timeout_at. Swept the rest of the file for the same shape. The credit wait re-arms a 1 s poll on purpose and is fine: its total bound is CREDIT_STALL, measured at the loop top from credit_since, and its comment already says the deadline is enforced there and not in the poll. That is the pattern the write path was missing. The read paths are single-shot bounded, not loops. Not covered by a test. Reproducing it needs a peer that accepts a little data just inside each window, so the scenario runs longer than the 5 s budget itself and a no-progress peer - the case a simple test would build - times out correctly under both the old code and the new. * drm: stop claiming the wake-less build cannot inject input It can. Dropping drm-wake removes injection from the CAPTURE path and nothing else: start_os_service calls start_uinput_service unconditionally, with no feature gate, so the root service runs RustDesk's keyboard and mouse uinput backends on every build, drm or not. That is how remote control works on Wayland and is not ours to change - but a maintainer auditing "is the injection path present in this build?" was being told no by a comment in the file most likely to be read for that question. The line now says what is actually true of the capture path and points at the ungated call, so the next reader is not sent to verify the wrong claim. The sentence is inherited: it came in with 2648ad0a2 and survived two review rounds because both were reading the comments I had just CHANGED, and this one I only re-wrapped. Re-wrapping is re-asserting. Also narrowed the wake arm's "the wait applies to every handshake that saw an undriven display": four early returns skip it - option off, nothing wakeable, no uinput, no recent wake to settle - and the same block asserts the first of them four lines later, so the paragraph contradicted itself. And "the trigger" in the shared block lost its antecedent when the wake paragraph moved onto the wake arm; it is "the signal" now, which is true for both arms. * drm: put two doc blocks on the items they describe Both pre-existing, both found by walking every doc run in the file down to the item it attaches to rather than by reading prose. handle_drm_conn's description was stranded: the block opened on the function and ended on the connection cap, so it attached to MAX_DRM_CONNS while the function itself had no doc at all. Moved the function's paragraph onto the function; the cap keeps its own. And drm_enumerate_all_displays ran its enumeration paragraph and its return-value paragraph together with no separator, so they read as one. Blank doc line between them. No text changed in either case - this is placement only. * drm: pin the send deadline with a test, and close five review findings The send deadline had no test, and I had written down that it could not have one: a peer that never reads times out correctly under the broken per-wait form too, so the obvious test proves nothing. That is true and it is not the whole answer. A peer that DRIPS separates them, and the first version I wrote still did not - draining a kilobyte at a time never makes the socket writable again, because Linux asserts POLLOUT on a stream socket only once a decent fraction of the send buffer is free, so the sender saw one long readiness wait and both forms timed out identically. At 64 KiB the socket really does re-arm and the two diverge. Measured both ways: the test passes in five seconds against the fix and fails at twenty against the per-wait form, with the message it exists to print. The chunk size is documented in the test for exactly that reason. Four more, all verified against the code before touching it: grab()'s SAFETY block claimed the frame is "released on every path". The ret < 0 arm returns without releasing, because a failed grab_mapped leaves nothing to release. Its two siblings, grab_desc and cursor, already state the distinction precisely; this was the loose copy, and the release protocol is the reason the block is unsafe in the first place. drmtap_dl.rs still said minor bumps are additive and compatible. abi_accepted requires an exact minor match and the block below it explains why, so the file argued both sides and the stale half is an invitation to widen the gate. grab_desc validated width, height and plane count but not pitch or offset, while the converter bounds pitch * height + offset per plane. Same bound on the export side now, so both halves refuse the same descriptors - the principle grab() already states. No pixel access happens there, so this is not an out-of-bounds fix; it keeps a bogus pitch off the wire and puts the rejection on the side that can name the device. And the deb staging interpolated so_path unquoted, which breaks on a path with a space (DRMTAP_PREBUILT_DIR is user-supplied). Also covers the regular-file case through the new removal helper - the stale socket every restart hits, which the existing file test reaches by another path. * build: quote the rest of the path interpolations, not just the two that were named The previous commit quoted so_path and stopped there, which left the six shell commands that build libdrmtap interpolating src and build_dir bare. Both derive from repo_root, which is built from __file__, so a checkout under a path with a space splits the argument and git init, git remote add, git fetch, git checkout, meson setup and meson compile all fail with an error that says nothing about the real cause. Same defect, same fix, and quoting one pair while leaving its siblings is the shape a reviewer finds next. * fix(drm): close the review items on the capture backend Guard the producer thread, surface a swallowed spawn error, stop the CI feature list from drifting from build.py, and four smaller ones. Should-fix: - `start_os_service` started the DRM producer with a bare `thread::spawn`, the one spawn in this feature that was not built with `thread::Builder`. `spawn` panics if the thread cannot be created (EAGAIN under a thread or memory limit), and that panic unwinds out of `start_os_service` and takes the root service with it -- for a feature whose failure should only cost DRM capture. Builder + warn, like the other four. - `refresh_available_async` dropped the spawn result on the floor. There is no wedge (the single-flight guard moved into the closure and is dropped with it), but a refresh that can never start was invisible: the cached verdict just keeps being served past its TTL. The sibling spawn already logged; now both do. - The drm workflow hardcoded the cargo feature list because it packages with `--skip-cargo`, so `get_features()` in build.py and the CI line were two definitions of the same thing and only the drm/drm-wake half was asserted afterwards. Adds `build.py --print-features`, which prints the list those flags select and exits, so CI asks instead of repeating; the same flags now drive the compile and the packaging. The step asserts the answer really is a drm build before handing it to cargo, matching whole comma-separated tokens so a future feature merely containing "drm" cannot satisfy it. Smaller: - The ENOTSUP fallback in `drm_capture_worker` switched to the CPU path without clearing `stalled`, so stalls charged to the dma-buf path could trip MAX_STALLED early and close a connection the fallback was about to serve. - `FrameSlot` kept one recycled buffer and claimed at most one is idle at a time, which does not hold: the receive path supersedes an unconsumed frame while the encoder returns its borrow, and those two writers do not even share a lock, since the receive path takes a buffer and publishes in two separate acquisitions. The later write freed a scanout-sized allocation the recycler exists to keep. Two slots is the exact bound for three in-flight buffers. The existing test passed against this, so the new one counts the offers rather than asking whether any came back. - `get_cursor`/`get_cursor_data` use the memoised `is_x11()` while the capture path deliberately uses the unmemoised `scrap::is_x11()`. That is the right trade at cursor cadence, since the unmemoised form forks `loginctl` per call -- say so, because the surrounding code argues the opposite for its own callers. * docs(drm): cut the changelog prose out of the comments Removes passages that document this patch's own revision history rather than the code, including the four quoted in review. Deletions and one misplaced comment moved to the field it describes; no comment was reworded, so nothing here can state something new. - `drm_capturer.rs`: the `drm_clear_prefer_cpu` parenthetical (that function does not exist), "same mistake, same shape, as the two flags before it" (it names no identifier, and both sites it gestures at carry their own hazard comments), and "the comment was right and the code used the probing accessor anyway". - `drmtap_dl.rs`: "this test replaces one that asserted the opposite", and "that sentence used to live here" -- the instruction not to widen the gate on the strength of "minor bumps are additive" stays, since that is a live constraint rather than history. - `platform/linux.rs`: the "NOT REPRODUCIBLE ON OUR HARDWARE" provenance label. What it introduced survives and is the better form of the same warning: on the test host `is_gdm_user` does not match `gdm-greeter`, so that branch is dead there and the code is for display managers whose greeter user does match. - `ipc/drm.rs`: "and that sentence has already been wrong here twice". The warning it trailed stays, because a shorter gap really would make relighting easier and the constant should not be described as bounding how long a screen stays lit. - `build.py`: "the answer to an objection is one word, not a revert". Also moves the comment describing `cur` off `display`, where a field reorder had left it sitting above that field's own comment. Most of the remaining density is mechanism, measurement or a hazard, and is left alone: the pipe/SIGPIPE analysis, the physical-vs-logical rect comparison, the `wire_idx` vs `display` argument, the wake measurements (REL_X alone did not wake the panel; the device bind window), the F_DUPFD_CLOEXEC privilege-leak argument, and the SAFETY blocks. * docs(drm): condense the capture comments from 35% of lines to 6% The five DRM files were 2319 comment lines against 4181 of code. The rest of this repository runs at 3%, so they were roughly twelve times the surrounding density, and that was the fair reading of the review: the volume itself is what makes an 8k-line addition hard to review. They are now 302 lines. What went is rationale: alternatives considered and rejected, arguments for why a design is acceptable, restatements of what the next line of code plainly says, and the same fact repeated at several sites. What stayed is what a reader cannot recover from the code, kept to one or two lines each: - every SAFETY comment on an unsafe block (none was dropped) - ownership and release contracts with the libdrmtap C API, including which grabs own a frame and which must not release it - ordering requirements: announce a pending refresh before claiming the single-flight slot, take the busy flag before the spawn rather than inside the closure, never hold DRM_STATE while taking a per-display map - the flow-control protocol, both ends of it - wire-format and units conventions, and the cmsghdr alignment the control-buffer type exists to provide - measured facts, reduced to the measurement: which synthetic events wake an idle panel and which do not, and the device bind window - hazards on the world-connectable listener, including why the rejection paths log at debug or not at all No code changed: with comments and blank lines stripped, all five files are byte-identical to their previous contents. Tests are 111 in the rustdesk crate and 20 in scrap. * docs(drm): restore the wire_idx argument on the hotplug guard The condensation cut this one too far. Within minutes of the shortened version going up for review, a reviewer read the remaining line and proposed changing the probe from `display` to `wire_idx` -- which is the change that was already tried here and was wrong. So the argument is not rationale prose, it is what stops a plausible and incorrect edit to a guard in the capture path, and it goes back in at six lines: `bound_to` is an identity rather than a position, the swap below installs this list as the client-space DRM_STATE, and probing `wire_idx` would go quiet in precisely the case the guard exists to catch. * docs(drm): correct what an empty render_node means on the wire The condensed doc said "Empty = auto-select", which is false on the host that field exists for. `drm_capture_worker` computes `ambiguous_gpu = render_node.is_empty() && render_node_count() > 1` and folds it into `force_cpu`, so an unnamed exporter on a machine with several render nodes takes the CPU path rather than auto-selecting. It auto-selects only where there is a single node. * docs(drm): fix comment claims that do not match the code An audit that verified every comment claim against the CODE (rather than against the pre-condensation text, which is what the earlier pass did) found twenty that were false or unqualified. Some came from the condensation dropping a qualifier; several predate it. The ones that mattered most: - `drm_render.rs` said libEGL/libGLESv2 are loaded "never in the privileged root service". That is true of the split path only: the CPU fallback calls `drmtap_grab_mapped`, whose auto-process step reaches `drmtap_gpu_egl_convert` in the CALLING process. `DRM_CAPTURE_SECURITY.md` already documents this precisely, and `drm_reader.rs` already said "on this path"; this one comment had lost the qualifier. - "A miss is fail-closed" on the per-frame reauthorization: true for a non-root peer only, since `drm_peer_authorized` returns true for uid 0 before it compares against the active session. - The cursor body check was described as a no-op because the hidden sentinel supposedly arrives 0x0 with an empty body. It arrives 1x1 with four bytes, so the check is live. - "EVERY write to DRM_STATE goes through here": the TTL restamp writes directly, and the comment on that arm says so. - `open(crtc=0)` was described as selecting the "primary" CRTC; libdrmtap picks the first CRTC with a valid mode, and in that library "primary" names a plane. - `list_devices() == None` was described as leaving the caller on single-device auto-detect; the caller scans /dev/dri/card* itself. - The framing note claimed the whole channel is length-prefixed; the reverse-direction frame acks are bare bytes. Also corrects `buffer_id`, which was documented as the producer's stable pool key: it is fb_id tagged with a per-connection epoch and no consumer reads it today. No behaviour changes. One executable line is touched: the message string of a unit-test `assert!` that asserted the auto-select claim being corrected here. * docs(drm): fix the second primary-CRTC occurrence the audit flagged Same correction as the enumeration-side comment: libdrmtap auto-selects the first CRTC with a valid mode, and primary names a plane there. The audit had flagged both sites and only one was fixed. * feat(drm): move the libdrmtap pin to 0.5.2 and the ABI gate with it libdrmtap 0.5.2 is now on rustdesk-org, so the pin can move. It fixes the padded-framebuffer read: a scanout whose pitch exceeds width*bpp was decoded at the wrong stride, which is why the Touch Bar strip on an Apple T2 produced no image and was listed as a known limitation. The three parts have to land together, and build.py enforces it: the staged .so is cross-checked against the ABI constants parsed out of drmtap_dl.rs, so a pin without the gate (or a gate without the pin) fails the build rather than producing a deb whose capture can never start. - pin: cbc5e6af5 (0.4.15) -> 653de8c (0.5.2), in build.py, which is the single source of truth, plus the informational version comment in libs/scrap/Cargo.toml. - gate: DRMTAP_ABI_MINOR 4 -> 5 and the patch floor (4, 10) -> (5, 0). 0.4.x is now refused even though it carries the whole split API, because of the stride bug above. - the newer-minor rejection test now derives its cases from DRMTAP_ABI_MINOR rather than hardcoding 5, so the next bump cannot leave it asserting that the newly verified minor must be refused. That is exactly what the hardcoded list would have done here. - DRM_CAPTURE_SECURITY.md: the vetted window is now 0.5.x with x >= 0. Verified: the build fetches 653de8c by sha and meson produces libdrmtap.so.0.5.2, which the runtime gate accepts. Tests 111 in the rustdesk crate, 20 in scrap. * fix(drm): refuse --drm on the packaging paths that cannot honour it Blocking finding from review. `get_features()` gated only on `windows or osx`, but Linux has four packaging branches and only the deb one is drm-aware. On a host with pacman, yum or zypper, `--drm` compiled in `drm,drm-wake` and then packaged through a path that does not bundle libdrmtap, does not rename, adds no Conflicts/Provides and never runs `assert_staged_binary_is_drm()` -- emitting a package NAMED `rustdesk` carrying the consent-bypass backend and the root-side uinput injection. The distinctly named package is the informed consent this feature rests on, so those branches now refuse the flag instead. `linux_packaging_branch()` mirrors the elif chain in main() and is the single place that decides, so the check cannot silently disagree with the branch actually taken. Also from the same review: - the bare-soname dlopen fallback is no longer offered when running as root. It exists so an unpackaged development build can load a locally built .so, but it was also the one place where which file happens to be on the ld.so path decided what gets mapped into the CAP_SYS_ADMIN process. The packaged service finds the absolute path first regardless, and a root process that reaches the fallback has no bundled library at all, which is the PipeWire-fallback case rather than a reason to search. - `rm -f {so}` is quoted, like the neighbouring `cp` already was. - `Cargo.lock` is dropped as a CI path trigger. Measured over the last 100 commits it alone would have fired this workflow 13 times and the pair 24 times, each about two job-hours of vcpkg + flutter release build, almost always for a dependency the drm path never touches. - `abi_gate_rejects_a_library_from_before_the_split` no longer implies the patch floor is what refuses those versions; the minor mismatch is. The floor is vacuous by construction while it sits at patch 0 of the verified minor, so a second test asserts exactly that and turns into a tripwire the next time a floor lands mid-minor, as (4, 10) did. --- .github/workflows/drm-capture.yml | 449 +++++++ .gitignore | 4 +- Cargo.toml | 7 + build.py | 458 ++++++- docs/DRM_CAPTURE_SECURITY.md | 255 ++++ libs/scrap/Cargo.toml | 10 + libs/scrap/src/common/drm_reader.rs | 477 +++++++ libs/scrap/src/common/drm_render.rs | 184 +++ libs/scrap/src/common/drmtap_dl.rs | 410 ++++++ libs/scrap/src/common/mod.rs | 6 + src/ipc.rs | 63 + src/ipc/auth.rs | 11 + src/ipc/drm.rs | 1799 +++++++++++++++++++++++++++ src/ipc/fs.rs | 90 +- src/platform/linux.rs | 163 +++ src/server.rs | 21 + src/server/display_service.rs | 44 + src/server/drm_capturer.rs | 1670 +++++++++++++++++++++++++ src/server/input_service.rs | 49 +- src/server/wayland.rs | 226 ++++ 20 files changed, 6384 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/drm-capture.yml create mode 100644 docs/DRM_CAPTURE_SECURITY.md create mode 100644 libs/scrap/src/common/drm_reader.rs create mode 100644 libs/scrap/src/common/drm_render.rs create mode 100644 libs/scrap/src/common/drmtap_dl.rs create mode 100644 src/ipc/drm.rs create mode 100644 src/server/drm_capturer.rs diff --git a/.github/workflows/drm-capture.yml b/.github/workflows/drm-capture.yml new file mode 100644 index 000000000..2efc6eabd --- /dev/null +++ b/.github/workflows/drm-capture.yml @@ -0,0 +1,449 @@ +name: DRM capture (opt-in drm feature) + +# Least-privilege GITHUB_TOKEN. Every job here only checks out, builds and tests; the artifact +# up/download used by the deb job authenticates with the runtime token, not this one. Declared at +# the workflow level so the reusable bridge workflow called below inherits the same bound. +permissions: + contents: read + +# Supersede a stale run when a PR is pushed again; never cancel a master run, whose whole job is to +# record that a given commit on master was verified. +concurrency: + group: drm-capture-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# Everything CI-side about the opt-in `drm` backend lives here, so the stock CI and release workflows +# stay byte-identical to a build with the feature off. Nothing in this file runs unless a drm-related +# path changes (or someone dispatches it by hand), so a PR that does not touch the backend pays nothing. +# +# The stock `CI` workflow deliberately does NOT compile with `--features drm`: the shipped default is +# the drm-off configuration and that stays the primary verified one. + +on: + workflow_dispatch: + pull_request: + paths: + - "libs/scrap/src/common/drm_reader.rs" + - "libs/scrap/src/common/drm_render.rs" + - "libs/scrap/src/common/drmtap_dl.rs" + - "libs/scrap/src/common/mod.rs" + - "libs/scrap/Cargo.toml" + # The ROOT manifest is where the top-level `drm` feature is DEFINED, so a PR that changes + # what `drm` pulls in must not skip this workflow. `Cargo.lock` is deliberately NOT here: + # measured over the last 100 commits, it alone would have fired this workflow 13 times and + # the pair 24 times, each run costing about two job-hours for a full vcpkg + flutter release + # build, almost always for a dependency the drm path never touches. A lockfile bump that + # does affect it arrives with a manifest or source change, which is triggered above. + - "Cargo.toml" + - "src/ipc.rs" + - "src/ipc/**" + - "src/server/drm_capturer.rs" + - "src/server/wayland.rs" + - "src/server/display_service.rs" + # These three carry DRM wiring too (warm_availability, the cursor path in run_cursor, and the + # producer start + get_cursor/get_cursor_data), so a PR touching only them must not skip the + # whole drm verification. + - "src/server.rs" + - "src/server/input_service.rs" + - "src/platform/linux.rs" + - "build.py" + - ".github/workflows/drm-capture.yml" + push: + branches: + - master + # Deliberately the SAME list as the pull_request trigger above: a shorter one here means a push + # that touches only the missing paths (a squash merge, a direct push) skips re-verification. + paths: + - "libs/scrap/src/common/drm_reader.rs" + - "libs/scrap/src/common/drm_render.rs" + - "libs/scrap/src/common/drmtap_dl.rs" + - "libs/scrap/src/common/mod.rs" + - "libs/scrap/Cargo.toml" + # The ROOT manifest is where the top-level `drm` feature is DEFINED, so a PR that changes + # what `drm` pulls in must not skip this workflow. `Cargo.lock` is deliberately NOT here: + # measured over the last 100 commits, it alone would have fired this workflow 13 times and + # the pair 24 times, each run costing about two job-hours for a full vcpkg + flutter release + # build, almost always for a dependency the drm path never touches. A lockfile bump that + # does affect it arrives with a manifest or source change, which is triggered above. + - "Cargo.toml" + - "src/ipc.rs" + - "src/ipc/**" + - "src/server/drm_capturer.rs" + - "src/server/wayland.rs" + - "src/server/display_service.rs" + # These three carry DRM wiring too (warm_availability, the cursor path in run_cursor, and the + # producer start + get_cursor/get_cursor_data), so a PR touching only them must not skip the + # whole drm verification. + - "src/server.rs" + - "src/server/input_service.rs" + - "src/platform/linux.rs" + - "build.py" + - ".github/workflows/drm-capture.yml" + +env: + VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" + VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" + FLUTTER_VERSION: "3.24.5" + +jobs: + drm-tests: + name: drm unit tests (linux) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: false + swap-storage: false + + - name: Checkout source code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + persist-credentials: false + + - name: Install prerequisites + shell: bash + run: | + sudo apt-get -y update + sudo apt-get install -y \ + clang cmake curl gcc git g++ \ + libpam0g-dev libasound2-dev libunwind-dev \ + libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + libgtk-3-dev libpulse-dev libva-dev libvdpau-dev \ + libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ + libxdo-dev libxfixes-dev nasm wget + + - name: Setup vcpkg with Github Actions binary cache + uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 + with: + vcpkgDirectory: /opt/artifacts/vcpkg + vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} + + - name: Install vcpkg dependencies + shell: bash + run: $VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + targets: x86_64-unknown-linux-gnu + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + # The whole rustdesk-crate test set with the feature ON, not just the `_drm` ones by name: a name + # filter would skip the sibling asserts that also matter in this configuration, notably the one + # bounding `size_of::()`, which the new DmabufDesc variant grows. + # The two skips are the same ones the stock CI applies: both need a real display server and fail + # on a headless runner regardless of this feature. + - name: Run rustdesk crate tests with the drm feature + shell: bash + run: | + cargo test --locked --target x86_64-unknown-linux-gnu -p rustdesk --features drm \ + --no-fail-fast -- --skip test_get_cursor_pos --skip test_get_key_state + + # The capture backend itself lives in the scrap crate, so its unit tests are a separate + # package. `--lib` keeps this to unit tests; none of them touch a device or a display server. + - name: Run scrap crate tests with the drm feature + shell: bash + run: | + cargo test --locked --target x86_64-unknown-linux-gnu -p scrap --features drm --lib + + libdrmtap: + name: libdrmtap pin, build and .so contract + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Checkout source code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Install libdrmtap build deps + shell: bash + run: | + sudo apt-get -y update + sudo apt-get install -y meson ninja-build pkg-config libdrm-dev \ + libegl1-mesa-dev libgles2-mesa-dev + + # Exercises the real fetch-and-build path in build.py, which pins the commit by sha, so a bad or + # moved pin fails here rather than in a release job. + - name: Fetch the pinned libdrmtap and build the .so + shell: bash + run: | + python3 - <<'PY' + import importlib.util, sys + spec = importlib.util.spec_from_file_location("b", "build.py") + b = importlib.util.module_from_spec(spec) + sys.argv = ["build.py"] + spec.loader.exec_module(b) + so = b.build_libdrmtap_so() + print(f"::notice::built {so}") + open("so_path", "w").write(so) + PY + + # The shipped hot path is the EGL detile. libdrmtap degrades to a CPU-only stub when the egl or + # glesv2 pkg-config files are missing on the build host, and nothing else in the pipeline notices, + # so assert here that the object we would ship really carries EGL and really exports every symbol + # the runtime loader resolves. + - name: Assert the .so contract (EGL enabled, loader symbols present) + shell: bash + run: | + # Strict mode is load-bearing here: without it the trailing ::notice echo would return 0 + # and mask the `test "$missing" -eq 0` assertion, so the step would pass with a missing + # loader symbol or a CPU-only stub. (pipefail also keeps the grep -c pipelines honest.) + set -euo pipefail + SO="$(cat so_path)" + echo "checking $SO" + missing=0 + # Every symbol drmtap_dl.rs resolves, derived from the loader itself so the two cannot + # drift. The character class allows digits (a drmtap_grab_desc2 would otherwise be + # silently dropped from the loop), and the count is asserted below so a refactor of the + # loader away from b"..." literals cannot quietly turn this whole check into a no-op that + # iterates zero times and passes. + # `|| true` on the extraction pipelines: under set -e/pipefail a zero-match grep would + # abort the script before the explicit ::error guard below can say WHY it failed; the + # guard on nsyms is the intended reporter for that case. + syms=$(grep -oE 'b"drmtap_[a-z0-9_]+"' libs/scrap/src/common/drmtap_dl.rs \ + | sed 's/^b"//; s/"$//' | sort -u || true) + nsyms=$(echo "$syms" | grep -c . || true) + if [ "$nsyms" -lt 13 ]; then + echo "::error::extracted only $nsyms loader symbols from drmtap_dl.rs (expected >= 13); the extraction pattern no longer matches the loader" + missing=1 + fi + # Inspect the object ONCE into a variable, then match with bash's own pattern operator -- + # NO PIPE ANYWHERE IN THESE CHECKS. `anything | grep -q` under `set -o pipefail` reports a + # FALSE FAILURE as soon as the producer outruns the 64 KB pipe buffer: grep -q exits at the + # first match, the producer dies on SIGPIPE (141), and pipefail makes that the pipeline's + # status, so a library that HAS the symbol is reported as missing it. Measured on a real + # EGL-enabled .so (101 KB of `strings`, both markers present): the piped form reported both + # missing and failed the step. Note the obvious repair does NOT work -- materializing the + # output and then doing `printf '%s\n' "$var" | grep -q` keeps the pipe and just swaps the + # producer, and it fails identically (measured). Today's release-sized .so happens to fit in + # the buffer, which is the only reason this has not fired yet. + exported="$(nm -D --defined-only "$SO")" + strs="$(strings "$SO")" + for sym in $syms; do + # Line-anchored: wrap in newlines so the pattern can require a whole line, the same + # thing `grep " T $sym$"` was expressing. + if [[ $'\n'"$exported"$'\n' != *$'\n'*" T $sym"$'\n'* ]]; then + echo "::error::libdrmtap does not export $sym, which the runtime loader resolves" + missing=1 + fi + done + # EGL is reached by lazy dlopen, on purpose, so that the privileged process never links the + # vendor GL stack. That means there is NO DT_NEEDED entry and no undefined egl* symbol to look + # for: the naive ELF check reports "no EGL" on a perfectly good library. What a CPU-only stub + # build really lacks is the dlopen target name and the import call itself. + for s in "libEGL.so.1" "eglCreateImageKHR"; do + if [[ "$strs" != *"$s"* ]]; then + echo "::error::libdrmtap looks like a CPU-only stub (no $s): the EGL detile hot path is missing" + missing=1 + fi + done + test "$missing" -eq 0 + echo "::notice::libdrmtap .so contract ok ($nsyms loader symbols, EGL detile present)" + + # The bridge generator is a reusable workflow, so this calls the stock one instead of duplicating it. + generate-bridge: + uses: ./.github/workflows/bridge.yml + + drm-deb: + name: unattended-wayland deb (verification build) + needs: generate-bridge + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: false + swap-storage: false + + - name: Checkout source code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + persist-credentials: false + + - name: Restore bridge files + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bridge-artifact + path: ./ + + - name: Install prerequisites + shell: bash + run: | + sudo apt-get -y update + # Same list the stock linux job needs, plus the flutter desktop toolchain and the three + # libdrmtap build deps (libdrm and the mesa-specific EGL/GLES dev packages). + sudo apt-get install -y \ + clang cmake curl gcc git g++ ninja-build meson pkg-config \ + libpam0g-dev libasound2-dev libunwind-dev liblzma-dev \ + libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + libgtk-3-dev libpulse-dev libva-dev libvdpau-dev \ + libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ + libxdo-dev libxfixes-dev nasm wget \ + libdrm-dev libegl1-mesa-dev libgles2-mesa-dev + + - name: Setup vcpkg with Github Actions binary cache + uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 + with: + vcpkgDirectory: /opt/artifacts/vcpkg + vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} + + - name: Install vcpkg dependencies + shell: bash + run: $VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + targets: x86_64-unknown-linux-gnu + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Setup flutter + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 + with: + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} + + - name: Patch flutter + shell: bash + run: | + cd $(dirname $(dirname $(which flutter))) + # `[[ ... ]] && cmd` as the last line makes the STEP fail once FLUTTER_VERSION moves off + # the pinned value, because the failed test becomes the script's exit status. An explicit + # if/else skips instead. Reading the values from the environment rather than interpolating + # github expressions into the script also keeps this off zizmor's template-injection list. + # (spelled out in prose: a literal expression marker here, even in a comment, is parsed by + # actionlint and breaks workflow linting.) + if [[ "$FLUTTER_VERSION" == "3.24.5" ]]; then + git apply "$GITHUB_WORKSPACE/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff" + else + echo "::notice::flutter $FLUTTER_VERSION is not 3.24.5; skipping the dropdown patch" + fi + + - name: Build the unattended-wayland deb + shell: bash + run: | + set -euo pipefail + # The features have to be on the cargo line HERE, because the packaging line below passes + # --skip-cargo and never rebuilds: whatever this compiles is what ships. ASK build.py for + # the list rather than repeating it -- get_features() is the single definition of what + # these flags mean, and a hardcoded copy silently ships something other than what + # `build.py --drm` produces the moment that function changes. The flags must be the same + # on both lines for that to hold, so keep them in one variable. + DRM_BUILD_FLAGS=(--flutter --drm --hwcodec --unix-file-copy-paste) + FEATURES="$(python3 ./build.py "${DRM_BUILD_FLAGS[@]}" --print-features)" + echo "features from build.py: $FEATURES" + # Assert rather than trust: an empty or error-shaped value would otherwise become a cargo + # line that builds a stock binary, which only the staged-binary marker check would catch. + # Match whole comma-separated TOKENS, one feature at a time. A substring test would depend + # on the order get_features happens to append them (failing a correct build the day they + # are reordered) and would also match a future feature that merely contains "drm", the same + # trap build.py avoids by splitting on commas rather than testing a substring. + for want in drm drm-wake; do + case ",$FEATURES," in + *",$want,"*) ;; + *) echo "::error::build.py --print-features returned no '$want' feature: $FEATURES"; exit 1 ;; + esac + done + cargo build --locked --lib --release --features "$FEATURES" + python3 ./build.py "${DRM_BUILD_FLAGS[@]}" --skip-cargo + + # build.py exits 0 on some inner failures, so assert the artifact instead of trusting the status, + # and assert the two things that make it the drm variant at all. + - name: Assert the deb is a real drm build + shell: bash + run: | + # Strict mode so the mid-script checks can fail the step (without it only the LAST + # command's status counts and the greps above it are decorative). + set -euo pipefail + # Glob into an array and assert the COUNT. `deb="$(ls ...)"` aborted on zero matches + # before its own `test -n` could report, and on several matches produced a multi-line + # value whose `mv` failed with something unrelated to the real problem. + shopt -s nullglob + debs=(rustdesk-unattended-wayland-*.deb) + if [ "${#debs[@]}" -ne 1 ]; then + echo "::error::expected exactly one rustdesk-unattended-wayland-*.deb, found ${#debs[@]}: ${debs[*]-none}" + exit 1 + fi + deb="${debs[0]}" + echo "::notice::built $deb ($(stat -c %s "$deb") bytes)" + # Pipe-free for the same reason as the .so contract step above (see the comment there: + # a producer feeding a grep that can exit early is a SIGPIPE reported as a failure under + # pipefail). `grep -E` without -q reads to EOF so these two happen to be safe, but the + # shape is the hazard and the next `-q` added here would inherit it silently. + contents="$(dpkg -c "$deb")" + if [[ ! "$contents" =~ usr/lib/rustdesk/libdrmtap\.so\.0\.[0-9]+\.[0-9]+ ]]; then + echo "::error::the deb does not contain a versioned libdrmtap.so.0.x.y" + exit 1 + fi + if [[ "$contents" != *"usr/lib/rustdesk/libdrmtap.so.0 ->"* ]]; then + echo "::error::the deb does not contain the libdrmtap.so.0 soname symlink" + exit 1 + fi + # The library alone does not make this a drm build: build.py stages it whenever --drm is + # passed, independently of what was compiled, and the deb name is what tells a user this + # is the consent-bypass variant. Assert the BINARY too, by the absolute dlopen path that + # only exists when the feature is compiled in -- otherwise a stock binary could ship + # under the unattended-wayland name with a library it can never reach. + rm -rf /tmp/debassert && dpkg-deb -R "$deb" /tmp/debassert + if ! grep -qF /usr/lib/rustdesk/libdrmtap.so.0 /tmp/debassert/usr/share/rustdesk/lib/librustdesk.so; then + echo "::error::the packaged librustdesk.so has no libdrmtap dlopen path; this is not a drm build" + exit 1 + fi + mv "$deb" "${deb%.deb}-x86_64.deb" + + # MEASURE the glibc floor rather than describing it. This job builds on the runner instead of the + # ubuntu18.04 container the stock release debs use, so the artifact only runs on a host at least + # as new as the runner -- and that number belongs in the artifact NAME, because a comment in this + # file is not visible to whoever downloads it from the Actions UI. + - name: Measure the deb glibc floor + id: floor + shell: bash + run: | + # Strict mode for the same reason as the assert step above. The floor extraction gets an + # explicit rescue so a no-match grep reaches the `test -n` reporter instead of dying as a + # bare pipeline failure. + set -euo pipefail + # Same nullglob array + count assertion as the assert step above, for the same two + # reasons: under set -e a zero-match `ls` aborts before anything can report WHY, and + # several matches make `deb` multi-line so dpkg-deb fails with an unrelated error. (This + # was the sibling left behind when that one was fixed.) + shopt -s nullglob + debs=(rustdesk-unattended-wayland-*-x86_64.deb) + if [ "${#debs[@]}" -ne 1 ]; then + echo "::error::expected exactly one renamed deb to measure, found ${#debs[@]}: ${debs[*]-none}" + exit 1 + fi + deb="${debs[0]}" + rm -rf /tmp/debfloor && dpkg-deb -R "$deb" /tmp/debfloor + floor="$(objdump -T /tmp/debfloor/usr/share/rustdesk/lib/librustdesk.so \ + | grep -oE 'GLIBC_2\.[0-9]+' | sort -uV | tail -1 || true)" + test -n "$floor" + echo "floor=${floor#GLIBC_}" >> "$GITHUB_OUTPUT" + echo "::notice::deb requires ${floor} or newer (built on the runner, not the ubuntu18.04 release container)" + + # Verification artifact, deliberately NOT a release deliverable. The consent-free variant stays + # out of the published release either way; the name states the floor so nobody installs it on an + # older distro and hits a bare loader error. + - name: Upload the deb + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: rustdesk-unattended-wayland-x86_64-verification-glibc${{ steps.floor.outputs.floor }}.deb + path: rustdesk-unattended-wayland-*-x86_64.deb diff --git a/.gitignore b/.gitignore index d2e09a906..f51a5b8cd 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,6 @@ examples/**/target/ vcpkg_installed flutter/lib/generated_plugin_registrant.dart libsciter.dylib -flutter/web/ \ No newline at end of file +flutter/web/ +# libdrmtap is cloned at build time by build.py (not a submodule) +/third_party/libdrmtap/ diff --git a/Cargo.toml b/Cargo.toml index a7b2aca77..2fac88c00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,13 @@ default = ["use_dasp"] hwcodec = ["scrap/hwcodec"] vram = ["scrap/vram"] mediacodec = ["scrap/mediacodec"] +drm = ["scrap/drm"] +# The display wake, as its OWN compile gate on top of `drm`. Everything else in the drm backend +# READS (it captures a scanout); the wake WRITES, injecting one synthetic pointer event from the +# root service so a compositor that idle-disabled its outputs re-enables them. That is a different +# kind of operation and deserves a switch that can remove it from the binary entirely, without +# giving up DRM capture: `--features drm` builds the capture path with no wake code compiled in. +drm-wake = ["drm"] plugin_framework = [] linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"] unix-file-copy-paste = [ diff --git a/build.py b/build.py index 957961857..9ebcf0eba 100755 --- a/build.py +++ b/build.py @@ -1,12 +1,16 @@ #!/usr/bin/env python3 import os +import glob +import contextlib import pathlib import platform import zipfile import urllib.request import shutil import hashlib +import re +import subprocess import argparse import sys from pathlib import Path @@ -130,6 +134,19 @@ def make_parser(): action='store_true', help='Build with unix file copy paste feature' ) + parser.add_argument( + '--drm', + action='store_true', + help='Linux only: build the DRM/KMS capture backend (bundles libdrmtap.so, ' + 'dlopen-ed in-process by the root service). Off by default.' + ) + parser.add_argument( + '--print-features', + action='store_true', + help='Print the cargo feature list these flags select, and exit without building. For a ' + 'caller that runs its own cargo line and then packages with --skip-cargo: it can ask ' + 'for the list rather than repeat it, so the two cannot drift.' + ) parser.add_argument( '--skip-cargo', action='store_true', @@ -272,6 +289,24 @@ def external_resources(flutter, args, res_dir): shutil.copytree(f, f'{flutter_build_dir_2}{f.stem}') +def linux_packaging_branch(): + """Which packaging path `main()` will take on THIS host. + + MUST mirror the elif chain in main() (pacman / yum / zypper / else), and exists so `--drm` can + refuse a branch that is not drm-aware instead of silently producing a stock-named package with + the capture backend compiled in. Only the final `deb` branch reaches `build_flutter_deb`, which + is what bundles libdrmtap, renames the package, adds Conflicts/Provides and asserts the staged + binary really is a drm build. + """ + if os.path.isfile('/usr/bin/pacman'): + return 'pacman' + if os.path.isfile('/usr/bin/yum'): + return 'yum' + if os.path.isfile('/usr/bin/zypper'): + return 'zypper' + return 'deb' + + def get_features(args): features = ['inline'] if not args.flutter else [] if args.hwcodec: @@ -282,6 +317,30 @@ def get_features(args): features.append('flutter') if args.unix_file_copy_paste: features.append('unix-file-copy-paste') + if args.drm: + # Say so rather than quietly handing back a stock build: the backend is Linux-only, so on + # any other host the flag cannot be honoured and the resulting binary would look like a + # DRM build without being one. + if windows or osx: + raise Exception('--drm is Linux only') + # And only on the deb branch. The other three Linux paths (pacman/yum/zypper) package + # straight from `target/release` without bundling libdrmtap, without the rename, without + # Conflicts/Provides and without assert_staged_binary_is_drm() -- so they would emit a + # package NAMED `rustdesk` carrying the consent-bypass backend and the root-side uinput + # injection. The separate package name is the informed consent this feature rests on (see + # docs/DRM_CAPTURE_SECURITY.md), so refuse rather than ship a stock-named build of it. + branch = linux_packaging_branch() + if branch != 'deb': + raise Exception( + f'--drm is only supported on the deb packaging path; this host would package via ' + f'{branch}, which cannot bundle libdrmtap or name the package distinctly') + features.append('drm') + # The display wake is its own compile gate on top of `drm`, and the unattended package is + # exactly where it belongs: that variant exists to reach a machine nobody is sitting at, + # and a machine whose screen went dark is the case it is for. Dropping `drm-wake` from + # this line builds the same capture backend with no wake code in the binary at all. + # It is ALSO switchable at runtime; see OPTION_ENABLE_DRM_DISPLAY_WAKE. + features.append('drm-wake') if osx: if args.screencapturekit: features.append('screencapturekit') @@ -316,6 +375,271 @@ def ffi_bindgen_function_refactor(): 'sed -i "s/ffi.NativeFunction= floor + if not accepted: + raise Exception( + f'the libdrmtap being packaged is {so_ver[0]}.{so_ver[1]}.{so_ver[2]}, which the ' + f'runtime loader would REFUSE: drmtap_dl.rs accepts exactly major {major}, minor ' + f'{minor}, patch >= {floor[1]}. Shipping it produces a deb whose DRM capture can never ' + 'start. Move the build pin and the gate together, or fix whichever one is wrong.') + print(f'[drm] libdrmtap {so_ver[0]}.{so_ver[1]}.{so_ver[2]} satisfies the runtime ABI gate ' + f'(major {major}, minor {minor}, patch >= {floor[1]})') + + +def stage_libdrmtap_into_deb(so_path): + # Put the built libdrmtap object plus its soname symlink into the staged deb. Only the soname + # symlink is needed: libdrmtap is resolved by ABSOLUTE path (/usr/lib/rustdesk/libdrmtap.so.0) at + # the in-process dlopen site (drmtap_dl.rs), so the deb does NOT drop /usr/lib/rustdesk into the + # system-wide /etc/ld.so.conf.d search path, which would let this private library shadow a system + # library for every binary on the host (Debian Policy 10.2 forbids that). No ld.so.conf.d drop-in + # and no ldconfig trigger are shipped, so the stock postinst is used unchanged. + assert_so_satisfies_the_runtime_abi_gate(so_path) + so_basename = os.path.basename(so_path) + system2('mkdir -p tmpdeb/usr/lib/rustdesk') + # Quoted: so_path comes from the repo root or from DRMTAP_PREBUILT_DIR, either of which can + # contain a space, and an unquoted interpolation would split the argument and fail obscurely. + system2(f'cp "{so_path}" tmpdeb/usr/lib/rustdesk/') + system2(f'ln -sf "{so_basename}" tmpdeb/usr/lib/rustdesk/libdrmtap.so.0') + + +def retarget_control_to_drm_variant(): + # Rewrite the control file that generate_control_file just produced, instead of parameterizing that + # function: the stock packaging path stays exactly as upstream wrote it, and everything specific to + # this variant lives here. The variant installs the same files as the stock package, so it must + # conflict with and replace it: you install one or the other, never both. It also needs libdrmtap's + # own runtime deps, which the stock package has no reason to carry. + path = '../res/DEBIAN/control' + with open(path) as f: + lines = f.readlines() + out = [] + for line in lines: + if line.startswith('Package: rustdesk'): + out.append(f'Package: {DRM_PACKAGE_NAME}\n') + out.append('Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n') + elif line.startswith('Depends:'): + out.append(line.rstrip('\n') + ', libdrm2, libegl1, libgles2\n') + else: + out.append(line) + body = ''.join(out) + # Fail loudly rather than silently shipping a package that says `rustdesk`: a stock control file + # that stopped matching either anchor would otherwise produce a variant deb wearing the stock name. + if f'Package: {DRM_PACKAGE_NAME}\n' not in body or 'libegl1' not in body: + raise Exception(f'could not retarget {path} to the drm variant; upstream control layout changed') + with open(path, 'w') as f: + f.write(body) + + def build_flutter_deb(version, features): if not skip_cargo: system2(f'cargo build --locked --features {features} --lib --release') @@ -352,9 +676,22 @@ def build_flutter_deb(version, features): 'cp ../res/pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk') system2( "echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit") + # Bundle libdrmtap.so only when this build actually enabled the `drm` feature, so stock packages + # stay exactly what they were. The root service dlopens it in-process by absolute path. + # `features` is the comma-joined string, so split it: a bare substring test would also match any + # future feature merely containing "drm" (drm-lease, vaapi-drm) and rename the deb to the + # consent-bypass variant without --drm ever being passed. + ships_so = 'drm' in features.split(',') + if ships_so: + # Same artifact assertion as the --package path. Under --skip-cargo nothing here rebuilt the + # binary, so `features` says what was ASKED for while the staged bundle can be anything. + assert_staged_binary_is_drm() + stage_libdrmtap_into_deb(build_libdrmtap_so()) system2('mkdir -p tmpdeb/DEBIAN') generate_control_file(version) + if ships_so: + retarget_control_to_drm_variant() system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/') md5_file_folder("tmpdeb/") system2('dpkg-deb -b tmpdeb rustdesk.deb;') @@ -362,10 +699,68 @@ def build_flutter_deb(version, features): system2('/bin/rm -rf tmpdeb/') system2('/bin/rm -rf ../res/DEBIAN/control') os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version) + if ships_so: + # Named apart from the stock package so installing the consent-free variant is a deliberate act. + os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb') os.chdir("..") -def build_deb_from_folder(version, binary_folder): +DRMTAP_DLOPEN_MARKER = b'/usr/lib/rustdesk/libdrmtap.so.0' +# Present only when `drm-wake` is compiled in: the runtime option constant is itself +# #[cfg(feature = "drm-wake")] (src/ipc/drm.rs). The dlopen marker above cannot stand in for it - +# `--features drm` alone produces a binary that carries the dlopen path and NO wake code, and that +# is exactly the deb this assertion is here to refuse. +DRMTAP_WAKE_MARKER = b'enable-drm-display-wake' + + +def _carries_drmtap_marker(path, marker=DRMTAP_DLOPEN_MARKER): + # Chunked, with an overlap of len(marker)-1 so the marker cannot be missed at a chunk boundary: + # librustdesk.so is ~45 MB and there is no reason to hold it all in memory, and the `with` + # closes deterministically instead of relying on refcounting. + with open(path, 'rb') as f: + tail = b'' + while True: + chunk = f.read(1 << 20) + if not chunk: + return False + if marker in tail + chunk: + return True + tail = chunk[-(len(marker) - 1):] + + +def assert_staged_binary_is_drm(): + """The staged BINARY must really be a drm build before it is named the unattended-wayland + variant. That package conflicts with and replaces the stock one, so shipping a stock binary + under that name produces something that can never capture and cannot be installed alongside + what it replaced. The marker is the absolute dlopen path from drmtap_dl.rs, present only when + the feature is compiled in -- assert what was produced, not what was asked for. + + Called from BOTH packaging paths. It used to guard only one of them, and `--skip-cargo` (which + is how CI packages) reaches the other, where nothing had rebuilt the binary at all. + """ + binaries = [p for p in glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so') + + glob.glob('tmpdeb/usr/share/rustdesk/rustdesk') if os.path.isfile(p)] + if not any(_carries_drmtap_marker(p) for p in binaries): + raise Exception( + f'--drm was requested but the staged bundle does not look like a drm build (no ' + f'{DRMTAP_DLOPEN_MARKER.decode()} dlopen path in {binaries or "any staged binary"}); ' + 'refusing to package it as the unattended-wayland variant, which conflicts with and ' + 'replaces the stock package but could never capture') + # And the WAKE half. `--drm` enables `drm-wake` too (see get_features), and the deb is named and + # documented as the variant that can reach a machine whose screen has gone dark. The dlopen + # marker above does not distinguish them: `--features drm` alone carries it and has no wake code + # at all. Asserting only the first half is how a deb can be named for a feature it does not have. + if not any(_carries_drmtap_marker(p, DRMTAP_WAKE_MARKER) for p in binaries): + raise Exception( + f'--drm was requested but the staged binary has no {DRMTAP_WAKE_MARKER.decode()} ' + f'marker in {binaries or "any staged binary"}, so it was built without `drm-wake`; ' + 'refusing to package it as the unattended-wayland variant, which is named and ' + 'documented as the build that can wake an idle-disabled display. If this fired under ' + '--skip-cargo, the cargo line that produced the bundle is missing the feature: ' + '--features ...,drm,drm-wake') + + +def build_deb_from_folder(version, binary_folder, want_drm=False): os.chdir('flutter') system2('mkdir -p tmpdeb/usr/bin/') system2('mkdir -p tmpdeb/usr/share/rustdesk') @@ -389,9 +784,53 @@ def build_deb_from_folder(version, binary_folder): 'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop') system2( "echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit") + # Where the capture library comes from for a `--package --drm` build. Two shapes are + # supported, because two exist in practice: a bundle that already carries libdrmtap.so.0.* + # (someone staged it, e.g. a CI artifact), and a plain bundle, which is what every build path + # here actually produces -- the flutter deb builds the library straight into the staged deb, so + # nothing ever puts it inside the bundle folder. Demanding it in the bundle made this flag + # combination impossible to satisfy. + bundled_glob = glob.glob('tmpdeb/usr/share/rustdesk/libdrmtap.so.0.*') + bundle_carries_so = any(os.path.isfile(p) and not os.path.islink(p) for p in bundled_glob) + # The variant must be decided by the EXPLICIT --drm request, not merely by what happens to be + # staged: a bundle that carries the .so must NOT be shipped as the consent-bypass variant when + # --drm was never passed. + if bundle_carries_so and not want_drm: + raise Exception( + 'the staged bundle carries libdrmtap.so.0.* but --drm was not passed; refusing ' + 'to silently ship the consent-bypass unattended-wayland variant (pass --drm to ' + 'build it deliberately)') + if want_drm: + # Whichever shape we are in, the staged BINARY must really be a drm build. This is the + # property the old presence-of-the-.so test stood in for, badly: a stock binary packaged as + # the unattended-wayland variant would carry the consent-bypass name, conflict with and + # replace the stock package, and never be able to capture. The marker is the absolute + # dlopen path from drmtap_dl.rs, present only when the feature is compiled in -- the same + # kind of artifact assertion as _assert_so_has_egl, and for the same reason: assert what + # was produced, not what was asked for. + assert_staged_binary_is_drm() + if bundle_carries_so: + so = _single_real_so(bundled_glob, 'the staged --drm bundle') + # The THIRD artifact source, and the last one that was missing the check: --package + # takes the .so straight out of a bundle somebody else produced, so it has the same + # exposure as DRMTAP_PREBUILT_DIR (see the comment on that branch). A CPU-only stub + # would ship, the loader would accept it, and capture would degrade to PipeWire + # without a word. + _assert_so_has_egl(so) + stage_libdrmtap_into_deb(so) + system2(f'rm -f "{so}"') + system2('rm -f tmpdeb/usr/share/rustdesk/libdrmtap.so tmpdeb/usr/share/rustdesk/libdrmtap.so.0') + else: + # Build it here, exactly as the flutter deb path does (build_libdrmtap_so asserts the + # EGL backend itself). The library is independent of the staged binary. + stage_libdrmtap_into_deb(build_libdrmtap_so()) system2('mkdir -p tmpdeb/DEBIAN') generate_control_file(version) + # Keyed on the EXPLICIT request, not on what happened to be staged: by here a --drm build has + # its library in tmpdeb whichever of the two shapes it came from. + if want_drm: + retarget_control_to_drm_variant() system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/') md5_file_folder("tmpdeb/") system2('dpkg-deb -b tmpdeb rustdesk.deb;') @@ -399,6 +838,8 @@ def build_deb_from_folder(version, binary_folder): system2('/bin/rm -rf tmpdeb/') system2('/bin/rm -rf ../res/DEBIAN/control') os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version) + if want_drm: + os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb') os.chdir("..") @@ -473,6 +914,19 @@ def main(): parser = make_parser() args = parser.parse_args() + # Before anything with a side effect: this is a query, and a caller uses it to build the very + # binary it will then package. `get_features` stays the single definition of what a flag + # combination means; a caller that hardcodes the list instead is one edit away from compiling + # something other than what it ships. + if args.print_features: + # stdout carries the list and nothing else, so a caller can use it directly in a command + # substitution. `get_features` prints a human-readable line of its own; send that to stderr + # for this call rather than silencing it, which would change what every other path prints. + with contextlib.redirect_stdout(sys.stderr): + feats = ','.join(get_features(args)) + print(feats) + return + if os.path.exists(exe_path): os.unlink(exe_path) if os.path.isfile('/usr/bin/pacman'): @@ -488,7 +942,7 @@ def main(): portable = args.portable package = args.package if package: - build_deb_from_folder(version, package) + build_deb_from_folder(version, package, args.drm) return res_dir = 'resources' external_resources(flutter, args, res_dir) diff --git a/docs/DRM_CAPTURE_SECURITY.md b/docs/DRM_CAPTURE_SECURITY.md new file mode 100644 index 000000000..9f0c98600 --- /dev/null +++ b/docs/DRM_CAPTURE_SECURITY.md @@ -0,0 +1,255 @@ +# DRM/KMS capture — security model & threat model + +The optional `drm` feature adds a Linux capture backend that reads the active +scanout directly from DRM/KMS, **bypassing the xdg-desktop-portal consent +dialog**. It exists for unattended / login-screen / Wayland scenarios where the +portal prompt is not acceptable. Because it bypasses consent, treat it as a +**privileged, opt-in host-mode feature**, not a normal Wayland capture backend. + +## How it works + +Reading the active scanout needs `CAP_SYS_ADMIN` (to map other clients' +framebuffers). RustDesk's root `--service` already runs with `CAP_SYS_ADMIN`, so +the `drm` feature does the read **in-process in that root service**: it +`dlopen`s `libdrmtap.so` and calls it in direct mode — no privileged child, no +`setcap` helper. On the **default (split) path** the root service does not touch +pixels: it exports the active scanout as a DMA-BUF and passes just that +**read-only** fd to the unprivileged user `--server` over a dedicated +service-scoped IPC channel (`_drm`) via `SCM_RIGHTS`. The `--server` keeps an +**import-once EGLImage cache** (keyed on the buffer, so a given scanout buffer is +imported once and re-imports are elided), detiles/converts it to linear RGBA in +its own unprivileged address space, and feeds the encoder — so **on that path** +the root service never copies scanout pixels and never loads libEGL/libGLESv2 +(measured on the running service, see *Auditing*). Only the **CPU fallback path** +(used when the seat/driver cannot produce a transferable DMA-BUF, or the consumer +has no render node of its own, see *When the CPU fallback is chosen* below) +copies the scanout to packed BGRA inside the root service and streams those bytes +over `_drm`. + +**The no-GL property is a property of the default path, not of the process.** Be +precise about it, because the CPU fallback is the whole reason the split exists: +converting a scanout in-process means decoding whatever layout it is in, and a +tiled scanout (the common case on modern Intel and AMD) can only be decoded +through the GPU. `drmtap_grab_mapped` therefore reaches libdrmtap's auto-process +step, which lazily `dlopen`s libEGL/libGLESv2 **in the calling process** when the +scanout needs a GPU detile. So a host that has fallen back to the CPU path can +map the GL stack inside the `CAP_SYS_ADMIN` service. What the design does about +that is bound the cases: the fallback is entered only for the three reasons +listed below, never as a silent degradation of the split path (the loader refuses +a `libdrmtap` that cannot export the fd at all, precisely so "old library" cannot +turn into "convert in the privileged process"), and a linear or CPU-mappable +scanout is converted without touching GL. Every host measured here runs the split +path with zero GL regions in the service; a CPU-fallback host is a different +posture and is worth measuring separately. This mirrors the Windows +`portable_service` split (a privileged process captures, an unprivileged one +presents) but reuses RustDesk's own hardened IPC. + +- `libdrmtap.so` is loaded through a small `dlopen` loader (`drmtap_dl`); if the + library or one of its runtime deps is missing the load fails cleanly and the + caller falls back to the PipeWire/portal path. +- The loader also **refuses a library that cannot do the split** — and, more + broadly, any version outside the vetted window. Accepted is exactly the pinned + minor with a patch floor (currently `0.5.x`, `x >= 0`): an older minor is + refused (`0.4.x` included, even though it carries the split entry points, because + it decodes a padded scanout pitch at the wrong stride), and a **newer minor is + refused too** (`0.6.x` onward), because the loader mirrors C struct layouts that are only + field-by-field verified against the pinned minor; widening the window is a + deliberate act done together with re-verifying the layouts and moving the + build pin. Independently of the version report, a library that does not + actually export + `drmtap_grab_desc` / `drmtap_open_render` / `drmtap_convert_dmabuf` (a stale or + pre-release build) is refused as well. The only way to capture with such a library is the + in-process convert, which in the root service means loading the vendor GL stack + there, so it is refused and the caller falls back to PipeWire/portal. The + privileged process therefore never loads GL because of which file happened to + be on the load path; the CPU fallback below is entered only for a fact about + the seat or the consumer. +- The reader restricts the device it opens to a realpath under `/dev/dri/` + (`drm_reader.rs`); RustDesk always runs libdrmtap in direct in-process mode + (`helper_path` is `NULL`). **No `drmtap-helper` binary is built, shipped, or + installed by this package**: there is no `setcap`, no capability-bearing file, + and no capture group in this deployment. Being precise about what that does + and does not guarantee: an empty `helper_path` is not by itself a "helper + disabled" switch in the C. `find_helper` (`privilege_helper.c`) searches six + hardcoded paths, one of which is `/usr/lib/rustdesk/drmtap-helper`, the + directory this package installs into, and `fork`/`exec`s the first executable + it finds if the direct export ever returns `EACCES`/`EPERM`. Here that path is + unreachable for two independent reasons: the root service holds + `CAP_SYS_ADMIN` so the direct export succeeds, and the package builds only the + shared library, so no helper exists at any of those paths. They are all + root-writable-only, so a helper appearing there would not be an escalation + either, but the honest statement is "a privileged child is spawned only if a + helper binary exists at one of those fixed root-owned paths, and this package + never installs one", not "never". +- The `_drm` socket lives beside the hardened `_service` socket + (`/tmp/-service/ipc_drm`). It is `0666` so the unprivileged `--server` + can connect, but every accepted peer is authorized in `handle_drm_conn` + (`authorize_service_scoped_ipc_connection`: peer must be root or the active + session uid, with a `/proc//exe` identity match). Connectable is not + authorized. + +## Threat model + +- **Consent bypass.** This mode does not show the portal "select what to share" + prompt. On a misconfigured install it could expose the login screen, the lock + screen, or another local user's graphical session. +- **The scanout parse runs in the root service.** Moving the read in-process + removes the old `setcap` helper and its world-exec attack surface. On the + **default (split) path** the root service does only a **metadata-only** parse + of the scanout descriptor and exports the DMA-BUF fd; the untrusted-framebuffer + detile / pixel-format conversion runs in the **unprivileged `--server`**, + outside `CAP_SYS_ADMIN`. Export-side validation is therefore metadata-only — + geometry bounded to `<= MAX_DIM` (16384) and `num_planes` in `1..=4` + (`drm_reader.rs` `grab_desc`); there is **no fourcc gate** on the export side, + because the format check is delegated to the unprivileged converter, which + handles every format `libdrmtap` supports (XRGB/ARGB8888, 10-bit XR30/AR30, + HDR, CCS-compressed). The exported fd is **read-only**: `libdrmtap` exports the + DMA-BUF via `drmPrimeHandleToFD` with `DRM_RDWR` dropped (`O_RDONLY`), and + `drm_reader` `dup()`s it — which shares the same open file description and so + preserves that access mode — so the unprivileged consumer can map the scanout + for reading but never write into the live framebuffer. On the **CPU fallback + path** the pixel-format conversion / detile instead runs inside the + `CAP_SYS_ADMIN` service without a seccomp cage; there the frame copy has + format / stride / geometry and integer-overflow guards (`drm_reader.rs` + `grab`), and non-32bpp scanouts are rejected before the copy. The device is + realpath-gated to `/dev/dri/` on both paths. +- **`_drm` is a screen-content channel.** It is authorized per connection (see + above); without that authz any local process could read the screen. Authorization + is also **re-checked on every frame**, not only at accept, because DRM/KMS + capture is not session-scoped: it grabs the physical scanout of a CRTC no matter + which session owns the display. So when the active session changes -- a user + logging in at a greeter -- the greeter's `_drm` stream is CLOSED rather than + continued (`drm: _drm peer no longer matches the active session`; observed with + peer_uid=60578 against active_uid=1000, and the greeter's uinput channel goes + with it). That is what stops an outgoing greeter process from capturing the + logged-in user's screen. The cost is a reconnect, not the session: the client + re-establishes itself against the new session's `--server` on its own in about + 2.5 s (~3.6 s of dark screen, measured 2026-07-31). On the + **default (split) path** the channel carries the scanout DMA-BUF fd, passed to + the unprivileged `--server` over `SCM_RIGHTS` as a **read-only** descriptor + (the `--server` holds an import-once EGLImage cache, so a given scanout buffer + is imported once and re-imports are elided); the peer can map the scanout for + reading but cannot write it. The **CPU fallback path** instead carries plain + packed-BGRA bytes over the same authorized socket (no fd passing, no shared + memory). +- **When the CPU fallback is chosen.** The split path is the default; the + consumer asks the service for the CPU-converted frame in two cases: no render + node can be opened for this seat, or a previous convert on this display + already failed. A third case is a **multi-GPU safety fallback**: if + the service could not name the render node of the GPU that exports the scanout + (an older `libdrmtap` without `drmtap_render_node`) and the host has more than + one render node, the consumer refuses to guess one, because importing a scanout + on a device that did not export it can succeed and return corrupted pixels + rather than fail. The conversion then happens in the service, on the device it + already has open, so it is correct by construction. Hosts with a single render + node have nothing to pick wrong and keep the DMA-BUF fast path. +- **The display wake injects synthetic input from the root service.** It is + compiled in only with the `drm-wake` feature, which `build.py --drm` adds on + top of `drm`, and it can be switched off at runtime with + `enable-drm-display-wake=N`. Building with `--features drm` alone leaves no + wake code in the binary at all, so an operator auditing the deb can answer + "is the injection path even present here?" from the artifact. A + compositor that idles long enough DISABLES a connector, leaving no scanout for + any backend, so on a `_drm` handshake that finds a CONNECTED display with no + CRTC the service emits one synthetic pointer round trip over `/dev/uinput` to + make the compositor re-enable it. The virtual device **declares** two relative + axes and `BTN_LEFT`, because libinput classifies a device before it will treat + its events as pointer activity at all and a single axis with no buttons is + ignored outright (measured three ways on the same idle machine). What it + actually **emits** is `+1` then `-1` on one axis: net-zero displacement, no + button press, no key events. This is deliberate input injection by privileged + code, so its bounds are worth stating precisely: + - it can only be reached through an **already-authorized** `_drm` connection + (same per-connection authz as every other use of the channel), so it grants + nothing to a local attacker that the channel itself does not; + - it runs in the root service because that is the only place it can: + `/dev/uinput` is root-only here, and a modeset of our own is not an option + since the compositor holds DRM master (the sysfs `dpms` attribute is + read-only). Session-bus routes (`org.gnome.ScreenSaver`) authenticate by + uid, refuse root, and are desktop-specific; + - the trigger is narrow — a connected-but-undriven connector, not "no + frames" — and connectors a wake demonstrably cannot bring back are + remembered by connector identity and stop triggering. That memory is + per-connector rather than global, so a permanently dark connector cannot + suppress the wake for a different panel, and it drops any entry later seen + scanning out. Note what that recovery rule does and does not give you: it + clears the moment the display is driven **by anything**, but nothing else + retries, so a connector latched after a wake that failed for a transient + reason stays latched until that display comes back some other way — on an + unattended host, typically not until the service restarts. It is a + deliberate trade against waking on every connection forever for a display + that is never coming; + - it is rate limited to **one wake per 20 s process-wide** with exactly one + concurrent winner (compare-exchange claim), so a reconnect storm cannot + become an input-injection storm. That bounds the injection RATE. It does + not bound how long a screen stays lit, and neither does the one-shot + property below: 20 s is shorter than every idle period measured below, so a + remote peer that reconnects in a loop can have the panel relit after each + idle-off. What that peer gains is a lit panel on a machine whose screen it + is already authorized to watch: it is visible to someone standing there, + not additional access; + - the wake is **one-shot: it resets the compositor's idle timer, it does not + hold the display on**. If nothing else keeps the session awake, the connector + idles off again one full idle period later -- measured 2026-07-31: 30.3 s at + a GDM greeter, 70.3 s in a user session with `idle-delay=60`. Keeping a + screen lit for the length of a session is the job of RustDesk's existing + keep-awake inhibitor, not of this wake, which only recovers a connector that + is *already* dark; + - the uinput device is created and destroyed around the emit — nothing + persists in the input stack between wakes; + - without `/dev/uinput` the wake is skipped and latched off. Such a session + was already view-only (input injection on Wayland needs uinput too), so + this adds no new failure mode. + +## Deployment + +- **Off by default.** The `drm` feature is **not** in the default feature set and + is **not** enabled in standard release packages; the drm-off build is + byte-identical to upstream. Build it explicitly with + `python3 build.py --flutter --drm` (Linux only). +- **Separate opt-in package.** A `--drm` build ships as a distinctly named + `rustdesk-unattended-wayland` package (Conflicts/Replaces/**Provides** `rustdesk` -- + `Provides` is what lets a third-party package that depends on `rustdesk` be satisfied by the + consent-free variant, so it belongs in an audit of this metadata), so + enabling consent-free capture is an explicit install choice. +- **Bundled library, no capabilities.** The package installs the versioned + `libdrmtap.so.0..` plus a `libdrmtap.so.0` soname symlink under + `/usr/lib/rustdesk/`, and the in-process `dlopen` names that absolute path + (`/usr/lib/rustdesk/libdrmtap.so.0`). The package deliberately does **not** + register the directory with the dynamic linker: no + `/etc/ld.so.conf.d/` drop-in and no `ldconfig` trigger are shipped, so a + private library cannot shadow a system one for unrelated binaries + (Debian Policy 10.2). The bare-soname lookups remain only as a fallback for a + development build reached through `LD_LIBRARY_PATH`. + + There is no `setcap`, no `rustdesk-capture` group, and no privileged binary: + the capture runs inside the root `--service`, which already holds the + capability it needs. Hosts without `/dev/dri` access (or where the library + fails to load) transparently fall back to the PipeWire/portal path. +- **Minimum libdrm: 2.4.95.** `libdrmtap` needs the DRM `GetFB2` framebuffer API, which + landed in libdrm 2.4.95. Ubuntu 18.04 is the oldest distribution worth naming here, and it + straddles the floor: base bionic shipped 2.4.91, below it, while the updates/HWE stack + (2.4.101) is above — so read this as "18.04 with updates, or anything newer", not as + "any 18.04". That is an API statement, not a binary-compatibility one: + the `rustdesk-unattended-wayland` deb in this repo's CI is built on an ubuntu-24.04 runner, so the + shipped binaries carry that build host's glibc floor. Running on an older distribution means + building the deb there (or in a matching container), which the libdrm floor above permits. + Capture also requires an active KMS scanout (a Wayland/KMS session with a display + on); on hosts where the compositor drives the display outside DRM/KMS (e.g. the proprietary NVIDIA + X11 stack) there is no capturable CRTC and the path falls back to PipeWire/portal. +- **Recommended for** single-user, physically-controlled, or unattended hosts. + +## Auditing + +```bash +# the bundled capture library and its soname symlink — no capabilities are set on either +ls -l /usr/lib/rustdesk/libdrmtap.so.0* +# the dlopen names the symlink by absolute path, so what matters is where the symlink points: +readlink /usr/lib/rustdesk/libdrmtap.so.0 # expect: the versioned object shipped by the package +# and there should be no other object left beside it (a leftover is not loaded on its own, but it +# is what a stray ldconfig over this directory would repoint the symlink to): +ls /usr/lib/rustdesk/libdrmtap.so.0.* # expect: exactly one versioned object +ls /etc/ld.so.conf.d/ | grep -i rustdesk # expect: no output (none is shipped) +# confirm no privileged helper is present (there should be none) +getcap -r /usr/lib/rustdesk 2>/dev/null # expect: no output +``` diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index 0af7dfe0f..da056b46d 100644 --- a/libs/scrap/Cargo.toml +++ b/libs/scrap/Cargo.toml @@ -11,6 +11,16 @@ edition = "2018" [features] wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", "zbus"] +# `drm` is a pure runtime-dlopen backend: rustdesk loads `libdrmtap.so.0` at runtime (`drmtap_dl.rs`) +# and NEVER link-time links it, so the graceful PipeWire fallback when the .so or EGL is absent is +# preserved and the drm build pulls in no libdrm/seccomp/cap/EGL link-time deps. The .so is pinned by +# `DRMTAP_SHA` in build.py, which fetches that exact commit (libdrmtap v0.5.2). We deliberately do +# NOT depend on the `libdrmtap-sys` crate: its build.rs statically compiles the whole libdrmtap C tree +# and a CAP_SYS_ADMIN helper and emits `-ldrm -lseccomp -lcap`, which would defeat the dlopen model. +# Depends on `wayland`: the three drm modules live inside the `#[cfg(feature = "wayland")]` arm of +# common/mod.rs, so `scrap/drm` on its own would compile nothing. The root crate happens to always +# enable `scrap/wayland`, which is what hid this. +drm = ["wayland"] mediacodec = ["ndk"] linux-pkg-config = ["dep:pkg-config"] hwcodec = ["dep:hwcodec"] diff --git a/libs/scrap/src/common/drm_reader.rs b/libs/scrap/src/common/drm_reader.rs new file mode 100644 index 000000000..3d19c6c41 --- /dev/null +++ b/libs/scrap/src/common/drm_reader.rs @@ -0,0 +1,477 @@ +// Service-side DRM/KMS read engine, in the ROOT `--service`: libdrmtap reads the scanout in-process (direct mode). The DRM_DEVICE env is not consulted here. + +use super::drmtap_dl::{ + self, drmtap_config, drmtap_ctx, drmtap_cursor_info, drmtap_device, drmtap_display, + drmtap_dmabuf_desc, drmtap_frame_info, DrmtapLib, +}; +use hbb_common::log; +use std::ffi::CString; +use std::io; +use std::os::fd::{FromRawFd, OwnedFd}; + +// Trust-boundary limits and formats `drm_render` (the unprivileged converter) imports: two copies that drift apart would weaken one side. +// 16384 covers 8K+ with headroom; anything larger is rejected as a bogus/hostile geometry. +pub(crate) const MAX_DIM: u32 = 16384; +// 256 MiB covers an 8K BGRA frame (7680x4320x4 ~= 127 MiB) with margin. +pub(crate) const MAX_FRAME_BYTES: usize = 256 * 1024 * 1024; +// XRGB/ARGB are little-endian B,G,R,{X,A} in memory == `Pixfmt::BGRA`; XBGR/ABGR are R,G,B,{X,A} == `Pixfmt::RGBA`. +pub(crate) const DRM_FORMAT_XRGB8888: u32 = 0x3432_5258; // 'XR24' +pub(crate) const DRM_FORMAT_ARGB8888: u32 = 0x3432_5241; // 'AR24' +pub(crate) const DRM_FORMAT_XBGR8888: u32 = 0x3432_4258; // 'XB24' +pub(crate) const DRM_FORMAT_ABGR8888: u32 = 0x3432_4241; // 'AB24' + +/// Cursor id published when the plane reports the cursor hidden, so the id changes and, where the DRM cursor is authoritative, the client drops the last shape. +pub const HIDDEN_CURSOR_ID: u64 = u64::MAX; + +pub struct CursorSnapshot { + pub id: u64, + pub width: u32, + pub height: u32, + pub hotx: i32, + pub hoty: i32, + pub colors: Vec, +} + +/// One enumerated DRM display, physical geometry only (the server overlays the Wayland logical origin/scale where it can match one). +pub struct DisplaySnapshot { + pub name: String, + pub crtc_id: u32, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub active: bool, +} + +pub struct DrmDevice { + pub path: String, + /// Render node, or empty if this device has none. + pub render_node: String, + pub display_count: u32, +} + +/// Copy a fixed C char array into a `String`, stopping at the first NUL WITHIN the array, so a +/// field libdrmtap failed to terminate cannot read past it. +fn cstr_field(buf: &[std::os::raw::c_char]) -> String { + // SAFETY: c_char and u8 share size/alignment; the slice is the exact length of `buf`. + let bytes: &[u8] = + unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, buf.len()) }; + let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + String::from_utf8_lossy(&bytes[..end]).into_owned() +} + +/// Enumerate every DRM device with KMS resources. `None` = unavailable, too old, or failed (the caller then scans /dev/dri/card* itself); empty `Vec` = none found. +pub fn list_devices() -> Option> { + let lib = drmtap_dl::get()?; + let f = lib.list_devices?; + const MAX: usize = 16; + let mut raw: [drmtap_device; MAX] = unsafe { std::mem::zeroed() }; + // SAFETY: `raw` is MAX valid, zeroed drmtap_device slots; the call fills up to MAX and returns the count. + let n = unsafe { f(raw.as_mut_ptr(), MAX as std::os::raw::c_int) }; + if n < 0 { + log::warn!("drmtap_list_devices failed ({n}); using single-device auto-detect"); + return None; + } + let n = (n as usize).min(MAX); + Some( + raw[..n] + .iter() + .map(|d| DrmDevice { + path: cstr_field(&d.path), + render_node: cstr_field(&d.render_node), + display_count: d.display_count, + }) + .collect(), + ) +} + +/// The CANONICAL path, when `path` canonicalizes to a node directly under /dev/dri/, else `None`. +/// Callers must open the value returned: opening the original re-resolves every symlink component after the check. +pub(super) fn device_under_dev_dri(path: &str) -> Option { + let p = std::fs::canonicalize(path).ok()?; + if p.parent() == Some(std::path::Path::new("/dev/dri")) { + Some(p) + } else { + None + } +} + +/// An open DRM read context. Not Send/Sync deliberately (the raw ctx is used on one thread). +pub struct DrmReader { + lib: &'static DrmtapLib, + ctx: *mut drmtap_ctx, + buf: Vec, +} + +impl DrmReader { + /// Open the DRM device. `device = None` auto-detects, `Some(path)` is realpath-gated to /dev/dri/. `crtc_id = 0` auto-selects the first active CRTC. + pub fn open(device: Option<&str>, crtc_id: u32) -> Option { + let lib = drmtap_dl::get()?; + let device_cstr = match device { + None => None, + Some(d) => { + let Some(canonical) = device_under_dev_dri(d) else { + log::warn!("DRM device {d:?} is not under /dev/dri; refusing to open"); + return None; + }; + match canonical.to_str().and_then(|s| CString::new(s).ok()) { + Some(c) => Some(c), + None => return None, + } + } + }; + let cfg = drmtap_config { + device_path: device_cstr.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()), + crtc_id, + helper_path: std::ptr::null(), + debug: 0, + }; + // SAFETY: cfg is a valid struct; device_cstr outlives this call. + let ctx = unsafe { (lib.open)(&cfg) }; + drop(device_cstr); + if ctx.is_null() { + log::info!("drmtap_open failed; DRM capture unavailable"); + return None; + } + Some(DrmReader { + lib, + ctx, + buf: Vec::new(), + }) + } + + /// Grab one frame, tightly packed as BGRA (`w*4*h` bytes), into the internal buffer; valid until the next grab. + pub fn grab(&mut self) -> io::Result<(&[u8], usize, usize)> { + // SAFETY: ctx is valid; frame is zeroed before the call. The frame is released on every return path that OWNS one: a failing + // `drmtap_grab_mapped` leaves nothing to release, and releasing anyway would be a double free. + unsafe { + let mut frame: drmtap_frame_info = std::mem::zeroed(); + let ret = (self.lib.grab_mapped)(self.ctx, &mut frame); + if ret < 0 { + let errno = -ret; + if errno == hbb_common::libc::EAGAIN + || errno == hbb_common::libc::EBUSY + || errno == hbb_common::libc::EINTR + { + return Err(io::ErrorKind::WouldBlock.into()); + } + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_grab_mapped failed: errno {errno}"), + )); + } + if frame.data.is_null() || frame.width == 0 || frame.height == 0 { + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::ErrorKind::WouldBlock.into()); + } + let w = frame.width; + let h = frame.height; + let stride = frame.stride as usize; + // The row copy reads w*4 bytes from a source only stride*height bytes: reject sub-32bpp / insane geometry to avoid an OOB read. + if w > MAX_DIM || h > MAX_DIM || stride < (w as usize) * 4 { + log::warn!( + "DRM scanout not 32-bit BGRA-compatible ({w}x{h} stride {stride} fourcc {:#010x}); falling back", + frame.format + ); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + "unsupported DRM scanout format", + )); + } + // XBGR8888 passes the stride check but, labeled BGRA downstream, would ship red and blue swapped; a zero fourcc falls through to the stride invariant (kept for libdrmtap builds that do not set it). + if frame.format != 0 + && frame.format != DRM_FORMAT_XRGB8888 + && frame.format != DRM_FORMAT_ARGB8888 + { + log::warn!( + "DRM scanout fourcc {:#010x} is not BGRA-compatible; falling back", + frame.format + ); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + "unsupported DRM scanout format", + )); + } + let (w, h) = (w as usize, h as usize); + let frame_size = match w.checked_mul(4).and_then(|x| x.checked_mul(h)) { + Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => sz, + other => { + log::warn!( + "DRM scanout geometry {w}x{h} yields an out-of-range frame ({other:?} bytes); falling back" + ); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + "DRM scanout frame too large", + )); + } + }; + // Bound the SOURCE extent too: the row loop reads up to (h-1)*stride + w*4, and `y * stride` can overflow. + match stride.checked_mul(h) { + Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => {} + other => { + log::warn!( + "DRM scanout stride {stride} x {h} rows is out of range ({other:?} bytes); falling back" + ); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + "DRM scanout stride out of range", + )); + } + } + if self.buf.len() != frame_size { + self.buf.resize(frame_size, 0); + } + let src = frame.data as *const u8; + let dst = self.buf.as_mut_ptr(); + if stride == w * 4 { + std::ptr::copy_nonoverlapping(src, dst, frame_size); + } else { + for y in 0..h { + std::ptr::copy_nonoverlapping(src.add(y * stride), dst.add(y * w * 4), w * 4); + } + } + (self.lib.frame_release)(self.ctx, &mut frame); + Ok((&self.buf, w, h)) + } + } + + /// Render node of the GPU this reader captures from, so the converter binds to the device that EXPORTS the scanout: + /// importing across vendors can fail on an incompatible tiling modifier. `None` if the symbol is absent or the device is display-only. + pub fn render_node(&mut self) -> Option { + let f = self.lib.render_node?; + // SAFETY: self.ctx is valid; the returned pointer is owned by the context and stays valid until it is closed. + let ptr = unsafe { f(self.ctx) }; + if ptr.is_null() { + return None; + } + unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_str() + .ok() + .map(|s| s.to_owned()) + } + + /// Zero-copy EXPORT grab: fills a `drmtap_dmabuf_desc` (dma-buf fd, plane layout, HDR metadata) WITHOUT mapping, detiling or copying pixels, so on this + /// path the root process never loads libEGL/libGLESv2. The exported fd is READ-ONLY (libdrmtap drops `DRM_RDWR` and `dup` shares that open file + /// description), so the `--server` that receives it can map the scanout but never write the live framebuffer. Validation here is METADATA ONLY. + pub fn grab_desc(&mut self) -> io::Result<(OwnedFd, drmtap_dmabuf_desc)> { + let grab_desc = self.lib.grab_desc; + // SAFETY: self.ctx is valid; desc/frame are zeroed before the call. Only paths that reach a populated frame release it: on `-EINVAL` + // libdrmtap returns before allocating, a failed inner grab has already cleaned up, and on `-ENOTSUP` libdrmtap releases the frame itself. + unsafe { + let mut desc: drmtap_dmabuf_desc = std::mem::zeroed(); + let mut frame: drmtap_frame_info = std::mem::zeroed(); + let ret = grab_desc(self.ctx, &mut desc, &mut frame); + if ret < 0 { + let errno = -ret; + if errno == hbb_common::libc::EAGAIN + || errno == hbb_common::libc::EBUSY + || errno == hbb_common::libc::EINTR + { + return Err(io::ErrorKind::WouldBlock.into()); + } + if errno == hbb_common::libc::ENOTSUP { + // A distinct error so the caller degrades to the mapped/PipeWire path instead of tight-looping a rebuild. + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "drmtap_grab_desc: no transferable dma-buf (ENOTSUP)", + )); + } + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_grab_desc failed: errno {errno}"), + )); + } + // `desc.dma_buf_fd` is the canonical fd (what split_capture.c sends); `frame` owns it too and `frame_release` closes the library's copy. + let raw_fd = if desc.dma_buf_fd >= 0 { + desc.dma_buf_fd + } else { + frame.dma_buf_fd + }; + if raw_fd < 0 { + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::ErrorKind::WouldBlock.into()); + } + let w = desc.width; + let h = desc.height; + if w == 0 || h == 0 || w > MAX_DIM || h > MAX_DIM { + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + format!("DRM scanout geometry {w}x{h} out of range"), + )); + } + // No fourcc gate here: the converter handles every format libdrmtap supports, and gating here dropped convertible scanouts such as XR30. + let planes = if desc.num_planes == 0 { 1 } else { desc.num_planes }; + if planes > 4 { + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + format!("DRM scanout num_planes {} out of range (1..=4)", desc.num_planes), + )); + } + for p in 0..(planes as usize) { + let extent = (desc.pitches[p] as usize) + .checked_mul(h as usize) + .and_then(|rows| rows.checked_add(desc.offsets[p] as usize)); + match extent { + Some(end) if end <= MAX_FRAME_BYTES => {} + other => { + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "DRM scanout plane {p} out of range (offset {} pitch {} over {h} rows -> {other:?}, cap {MAX_FRAME_BYTES})", + desc.offsets[p], desc.pitches[p] + ), + )); + } + } + } + // dup BEFORE releasing the frame: after release the library may recycle its handle, while an independent fd on the same open dma-buf + // keeps the buffer alive for the peer. F_DUPFD_CLOEXEC, not dup(): `dup` never copies close-on-exec and this root service forks elsewhere. + let dup_fd = hbb_common::libc::fcntl(raw_fd, hbb_common::libc::F_DUPFD_CLOEXEC, 0); + if dup_fd < 0 { + let e = io::Error::last_os_error(); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(e); + } + let owned = OwnedFd::from_raw_fd(dup_fd); + (self.lib.frame_release)(self.ctx, &mut frame); + desc.num_planes = planes; + desc.dma_buf_fd = -1; + Ok((owned, desc)) + } + } + + /// Read the hardware cursor plane: the hidden sentinel when the plane reports the cursor invisible, the real shape when visible, and `None` when the read fails. + pub fn cursor(&mut self) -> Option { + // SAFETY: ctx valid; c zeroed; released on EVERY path after a successful get_cursor. Only a failed get_cursor returns without releasing, because then there is nothing to release. + unsafe { + let mut c: drmtap_cursor_info = std::mem::zeroed(); + let cret = (self.lib.get_cursor)(self.ctx, &mut c); + if cret != 0 { + return None; + } + let out = if c.visible == 0 { + Some(CursorSnapshot { + id: HIDDEN_CURSOR_ID, + width: 1, + height: 1, + hotx: 0, + hoty: 0, + colors: vec![0, 0, 0, 0], + }) + } else if !c.pixels.is_null() + && c.width > 0 + && c.height > 0 + && (c.width as i64) * (c.height as i64) <= 256 * 256 + { + let cw = c.width as i32; + let ch = c.height as i32; + let n = (cw * ch) as usize; + let src = std::slice::from_raw_parts(c.pixels, n); + let mut hash: u64 = 1469598103934665603; + let mut colors = Vec::with_capacity(n * 4); + let (mut minx, mut miny, mut maxx, mut maxy) = (cw, ch, -1i32, -1i32); + for (i, &p) in src.iter().enumerate() { + let a = ((p >> 24) & 0xff) as u8; + let r = ((p >> 16) & 0xff) as u8; + let g = ((p >> 8) & 0xff) as u8; + let b = (p & 0xff) as u8; + colors.push(r); + colors.push(g); + colors.push(b); + colors.push(a); + hash ^= p as u64; + hash = hash.wrapping_mul(1099511628211); + if a >= 128 { + let x = (i as i32) % cw; + let y = (i as i32) / cw; + if x < minx { minx = x; } + if x > maxx { maxx = x; } + if y < miny { miny = y; } + if y > maxy { maxy = y; } + } + } + let (hotx, hoty) = if c.hot_x != 0 || c.hot_y != 0 { + (c.hot_x, c.hot_y) + } else if maxx >= minx && maxy >= miny { + let (bw, bh) = (maxx - minx + 1, maxy - miny + 1); + if bh > bw * 2 { + ((minx + maxx) / 2, (miny + maxy) / 2) + } else { + (minx, miny) + } + } else { + (0, 0) + }; + // Fold geometry + hotspot into the id: identical pixels with a changed size or + // hotspot must count as a new shape, otherwise drm_capture_worker suppresses the + // update (it dedupes by id) and the client keeps rendering the stale cursor. + let mut id = hash; + for v in [cw as u32 as u64, ch as u32 as u64, hotx as u32 as u64, hoty as u32 as u64] { + id ^= v; + id = id.wrapping_mul(1099511628211); + } + Some(CursorSnapshot { + id, + width: cw as u32, + height: ch as u32, + hotx, + hoty, + colors, + }) + } else { + None + }; + (self.lib.cursor_release)(self.ctx, &mut c); + out + } + } + + pub fn displays(&mut self) -> Vec { + // SAFETY: ctx valid; raw is a zeroed, correctly-sized array; count is clamped to the buffer before indexing. + unsafe { + let mut raw = vec![std::mem::zeroed::(); 16]; + let cap = raw.len() as i32; + let n = (self.lib.list_displays)(self.ctx, raw.as_mut_ptr(), cap); + if n <= 0 { + return Vec::new(); + } + let count = (n as usize).min(raw.len()); + (0..count) + .map(|i| { + let name_bytes: Vec = raw[i] + .name + .iter() + .take_while(|&&ch| ch != 0) + .map(|&ch| ch as u8) + .collect(); + DisplaySnapshot { + name: String::from_utf8_lossy(&name_bytes).to_string(), + crtc_id: raw[i].crtc_id, + x: raw[i].x as i32, + y: raw[i].y as i32, + width: raw[i].width, + height: raw[i].height, + active: raw[i].active != 0, + } + }) + .collect() + } + } +} + +impl Drop for DrmReader { + fn drop(&mut self) { + if !self.ctx.is_null() { + // SAFETY: ctx came from drmtap_open and is non-null. + unsafe { (self.lib.close)(self.ctx) }; + self.ctx = std::ptr::null_mut(); + } + } +} diff --git a/libs/scrap/src/common/drm_render.rs b/libs/scrap/src/common/drm_render.rs new file mode 100644 index 000000000..f12df71f7 --- /dev/null +++ b/libs/scrap/src/common/drm_render.rs @@ -0,0 +1,184 @@ +// Unprivileged half of the split DRM/KMS capture path: the root `--service` exports a scanout +// dma-buf fd + descriptor, this side imports it and EGL-detiles. libEGL/libGLESv2 are dlopen'd +// in the UNPRIVILEGED process on this path; the root service loads them only if it falls back to +// its own CPU-mapped grab (`drmtap_grab_mapped`). See docs/DRM_CAPTURE_SECURITY.md. + +use super::drmtap_dl::{self, drmtap_ctx, drmtap_dmabuf_desc, drmtap_frame_info, DrmtapLib}; +use super::Pixfmt; +use hbb_common::log; +use std::ffi::CString; +use std::io; +use std::os::fd::RawFd; + +use super::drm_reader::{ + DRM_FORMAT_ABGR8888, DRM_FORMAT_ARGB8888, DRM_FORMAT_XBGR8888, DRM_FORMAT_XRGB8888, + MAX_DIM, MAX_FRAME_BYTES, +}; + +/// Unprivileged DRM render-node convert context. !Send/!Sync via the raw ctx pointer: the context +/// and libdrmtap's thread-local EGL state must be created, used (`convert`) and closed on ONE thread. +pub struct RenderConverter { + lib: &'static DrmtapLib, + ctx: *mut drmtap_ctx, +} + +impl RenderConverter { + /// `node` is the render node of the GPU that exports the scanout; `None`/invalid path falls back to libdrmtap auto-selection. + pub fn open_render(node: Option<&str>) -> Option { + let lib = drmtap_dl::get()?; + let open_render = lib.open_render; + let node_cstr = match node.filter(|n| !n.is_empty()) { + None => None, + // Open the CANONICAL path the gate resolved: opening the IPC string would re-walk its symlinks after the check. + Some(n) => match super::drm_reader::device_under_dev_dri(n) { + None => { + log::warn!("drm: render node {n:?} is not under /dev/dri; auto-selecting"); + None + } + Some(canonical) => canonical.to_str().and_then(|s| CString::new(s).ok()), + }, + }; + // SAFETY: resolved C entry point; `node_cstr` outlives the call, NULL requests auto-selection. + let ctx = unsafe { + open_render(node_cstr.as_ref().map_or(std::ptr::null(), |c| c.as_ptr())) + }; + if ctx.is_null() { + log::info!( + "drmtap_open_render({}) failed; no usable DRM render node", + node_cstr.as_ref().map_or("NULL".to_owned(), |c| format!("{c:?}")) + ); + return None; + } + match node_cstr { + Some(c) => log::info!( + "drm: opened unprivileged convert context on the exporting GPU ({c:?})" + ), + None => log::info!( + "drm: opened unprivileged render-node convert context (auto-selected)" + ), + } + Some(RenderConverter { lib, ctx }) + } + + /// Returns context-owned linear pixels valid ONLY until the next `convert()`; row stride is `len / height`. + pub fn convert( + &mut self, + desc: &mut drmtap_dmabuf_desc, + received_fd: RawFd, + ) -> io::Result<(&[u8], u32, u32, Pixfmt)> { + { + let (w, h) = (desc.width, desc.height); + if w == 0 || h == 0 || w > MAX_DIM || h > MAX_DIM { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("drm: refusing a dma-buf descriptor with geometry {w}x{h}"), + )); + } + // Reject, do not clamp, and write the normalized count back so the C reads the count bounded here. + let planes = if desc.num_planes == 0 { 1 } else { desc.num_planes }; + if planes > 4 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "drm: refusing a dma-buf descriptor with num_planes {} (1..=4)", + desc.num_planes + ), + )); + } + desc.num_planes = planes; + let planes = planes as usize; + for p in 0..planes { + let extent = (desc.pitches[p] as usize) + .checked_mul(h as usize) + .and_then(|rows| rows.checked_add(desc.offsets[p] as usize)); + match extent { + Some(end) if end <= MAX_FRAME_BYTES => {} + other => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "drm: refusing dma-buf plane {p} (offset {} pitch {} over {h} rows -> {other:?}, cap {MAX_FRAME_BYTES})", + desc.offsets[p], desc.pitches[p] + ), + )); + } + } + } + } + let convert_dmabuf = self.lib.convert_dmabuf; + // LOAD-BEARING: the fd the exporter serialized was process-local; -1 means reuse the cached import for `fb_id`. + desc.dma_buf_fd = received_fd; + // SAFETY: self.ctx is a valid render context; `desc` is fully initialized; `frame` is zeroed + // before the call. libdrmtap OWNS `frame.data`: no release/free from this side (drmtap.h). + unsafe { + let mut frame: drmtap_frame_info = std::mem::zeroed(); + let ret = convert_dmabuf(self.ctx, &*desc as *const drmtap_dmabuf_desc, &mut frame); + if ret < 0 { + let errno = -ret; + if errno == hbb_common::libc::EAGAIN + || errno == hbb_common::libc::EBUSY + || errno == hbb_common::libc::EINTR + { + return Err(io::ErrorKind::WouldBlock.into()); + } + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_convert_dmabuf failed: errno {errno}"), + )); + } + if frame.data.is_null() || frame.width == 0 || frame.height == 0 || frame.stride == 0 { + return Err(io::Error::new( + io::ErrorKind::Other, + "drmtap_convert_dmabuf produced an empty frame", + )); + } + let w = frame.width; + let h = frame.height; + let stride = frame.stride as usize; + // A stride below 32bpp under-sizes the row and, read as BGRA downstream, discloses adjacent memory. + if w > MAX_DIM || h > MAX_DIM || stride < (w as usize) * 4 { + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "drmtap_convert_dmabuf bad geometry {w}x{h} stride {stride} fourcc {:#010x}", + frame.format + ), + )); + } + let len = match stride.checked_mul(h as usize) { + Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => sz, + other => { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_convert_dmabuf frame size out of range ({other:?} bytes)"), + )); + } + }; + let pixfmt = match frame.format { + DRM_FORMAT_XRGB8888 | DRM_FORMAT_ARGB8888 => Pixfmt::BGRA, + DRM_FORMAT_XBGR8888 | DRM_FORMAT_ABGR8888 => Pixfmt::RGBA, + // Unset by an older convert -> libdrmtap's normalized BGRA. + 0 => Pixfmt::BGRA, + other => { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_convert_dmabuf produced an unsupported output fourcc {other:#010x}"), + )); + } + }; + let data = std::slice::from_raw_parts(frame.data as *const u8, len); + Ok((data, w, h, pixfmt)) + } + } +} + +impl Drop for RenderConverter { + fn drop(&mut self) { + if !self.ctx.is_null() { + // SAFETY: ctx came from drmtap_open_render and is non-null; the !Send ctx pointer keeps + // this drop on the thread that created and used it (thread-local EGL + cached imports). + unsafe { (self.lib.close)(self.ctx) }; + self.ctx = std::ptr::null_mut(); + } + } +} diff --git a/libs/scrap/src/common/drmtap_dl.rs b/libs/scrap/src/common/drmtap_dl.rs new file mode 100644 index 000000000..63b46ce8b --- /dev/null +++ b/libs/scrap/src/common/drmtap_dl.rs @@ -0,0 +1,410 @@ +// Runtime loader for libdrmtap.so (the DRM/KMS capture engine), dlopen'd so the binary carries no hard libdrm/libEGL/libGLESv2 dependency. + +use hbb_common::{libloading::Library, log}; +use std::os::raw::{c_char, c_int, c_void}; +use std::sync::OnceLock; + +// C ABI structs: must match libdrmtap include/drmtap.h. + +#[repr(C)] +pub struct drmtap_ctx { + _private: [u8; 0], +} + +#[repr(C)] +pub struct drmtap_config { + pub device_path: *const c_char, // NULL = auto-detect /dev/dri/card* + pub crtc_id: u32, // 0 = auto-select first active CRTC + pub helper_path: *const c_char, // only consulted if the direct DRM export is denied (no CAP_SYS_ADMIN) + pub debug: c_int, +} + +impl Default for drmtap_config { + fn default() -> Self { + Self { + device_path: std::ptr::null(), + crtc_id: 0, + helper_path: std::ptr::null(), + debug: 0, + } + } +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct drmtap_display { + pub crtc_id: u32, + pub connector_id: u32, + pub name: [c_char; 32], + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, + pub refresh_hz: u32, + pub active: c_int, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct drmtap_device { + pub path: [c_char; 64], + pub render_node: [c_char; 64], + pub driver: [c_char; 32], + pub display_count: u32, +} + +#[repr(C)] +pub struct drmtap_frame_info { + pub data: *mut c_void, + pub dma_buf_fd: c_int, + pub width: u32, + pub height: u32, + pub stride: u32, + pub format: u32, + pub modifier: u64, + pub fb_id: u32, + pub _priv: *mut c_void, +} + +// Descriptor of an externally-supplied scanout DMA-BUF: the privileged exporter fills it via +// `drmtap_grab_desc`; the converter overwrites `dma_buf_fd` with the fd it got via SCM_RIGHTS. +// Mirrors `drmtap_dmabuf_desc` EXACTLY (field order + widths); a mismatch mis-reads CCS/HDR scanouts. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct drmtap_dmabuf_desc { + pub dma_buf_fd: c_int, // scanout DMA-BUF; -1 for an already-imported fb_id + pub width: u32, + pub height: u32, + pub format: u32, // DRM fourcc of the scanout + pub modifier: u64, // DRM format modifier (tiling/compression) + pub fb_id: u32, // import-once cache key; 0 disables caching + pub num_planes: u32, // used entries in offsets/pitches (1..4); 0 => 1 + pub offsets: [u32; 4], // per-plane byte offsets (CCS main+aux+clear-color) + pub pitches: [u32; 4], // per-plane strides; pitches[0] = main stride + pub hdr_eotf: u32, // DRMTAP_EOTF_* (SDR=0, PQ=2, HLG=3) + pub hdr_max_nits: u32, // mastering/content peak luminance cd/m2; 0=unknown +} + +impl Default for drmtap_dmabuf_desc { + fn default() -> Self { + Self { + dma_buf_fd: -1, + width: 0, + height: 0, + format: 0, + modifier: 0, + fb_id: 0, + num_planes: 0, + offsets: [0; 4], + pitches: [0; 4], + hdr_eotf: 0, + hdr_max_nits: 0, + } + } +} + +#[repr(C)] +pub struct drmtap_cursor_info { + pub x: i32, + pub y: i32, + pub hot_x: i32, + pub hot_y: i32, + pub width: u32, + pub height: u32, + pub pixels: *mut u32, + pub visible: c_int, + pub _priv: *mut c_void, +} + +// Resolved symbol typedefs. + +type FnVersion = unsafe extern "C" fn() -> c_int; +type FnOpen = unsafe extern "C" fn(*const drmtap_config) -> *mut drmtap_ctx; +type FnClose = unsafe extern "C" fn(*mut drmtap_ctx); +type FnListDisplays = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_display, c_int) -> c_int; +type FnListDevices = unsafe extern "C" fn(*mut drmtap_device, c_int) -> c_int; +type FnGrabMapped = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info) -> c_int; +type FnFrameRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info); +type FnGetCursor = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info) -> c_int; +type FnCursorRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info); +// Split-capture entry points (libdrmtap >= 0.4.10), required: `grab_desc` runs on the privileged +// export side, `open_render`/`convert_dmabuf` on the unprivileged converter side. +type FnGrabDesc = + unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_dmabuf_desc, *mut drmtap_frame_info) -> c_int; +type FnOpenRender = unsafe extern "C" fn(*const c_char) -> *mut drmtap_ctx; +// libdrmtap >= 0.4.15; returns a ctx-owned string, or NULL if it has none. +type FnRenderNode = unsafe extern "C" fn(*mut drmtap_ctx) -> *const c_char; +type FnConvertDmabuf = + unsafe extern "C" fn(*mut drmtap_ctx, *const drmtap_dmabuf_desc, *mut drmtap_frame_info) -> c_int; + +/// The dlopen'd libdrmtap; the `Library` is kept alive for the process lifetime, so the raw fn pointers stay valid. +pub struct DrmtapLib { + _lib: Library, + pub open: FnOpen, + pub close: FnClose, + pub list_displays: FnListDisplays, + pub list_devices: Option, + pub grab_mapped: FnGrabMapped, + pub frame_release: FnFrameRelease, + pub get_cursor: FnGetCursor, + pub cursor_release: FnCursorRelease, + pub grab_desc: FnGrabDesc, + pub open_render: FnOpenRender, + pub convert_dmabuf: FnConvertDmabuf, + pub render_node: Option, + pub version: (c_int, c_int, c_int), +} + +// SAFETY: the resolved fn pointers are plain C entry points with no interior mutability; +// libdrmtap contexts are used single-threaded by the caller. The Library handle is never moved out. +unsafe impl Send for DrmtapLib {} +unsafe impl Sync for DrmtapLib {} + +const DRMTAP_ABI_MAJOR: c_int = 0; + +// Lowest (minor, patch) accepted. 0.5.0 is the floor because it fixes the padded-framebuffer read +// (a scanout whose pitch exceeds width*bpp was decoded at the wrong stride); the whole split API +// has been present since 0.4.10. +const DRMTAP_MIN_MINOR_PATCH: (c_int, c_int) = (5, 0); + +// The MINOR series this build's mirrored structs were verified against: libdrmtap's header freezes +// only `drmtap_device` and `drmtap_dmabuf_desc`, so an unverified minor could be read at wrong offsets. +const DRMTAP_ABI_MINOR: c_int = 5; + +/// Whether a library reporting `major.minor.patch` may be loaded (major and minor exact, patch at or above the floor). +fn abi_accepted(major: c_int, minor: c_int, patch: c_int) -> bool { + major == DRMTAP_ABI_MAJOR + && minor == DRMTAP_ABI_MINOR + && (minor, patch) >= DRMTAP_MIN_MINOR_PATCH +} + +impl DrmtapLib { + fn load() -> Option { + // Absolute path FIRST: the deb bundles the .so privately under /usr/lib/rustdesk and does NOT register that dir with ld.so. + const INSTALLED: &str = "/usr/lib/rustdesk/libdrmtap.so.0"; + // Bare sonames exist so an unpackaged development build can load a locally built .so from + // the normal ld.so search path. They are NOT offered when running as root: this is the one + // place where which file happens to be on the load path decides what gets mapped into the + // CAP_SYS_ADMIN process, and the packaged service always finds the absolute path first + // anyway. A root process that reaches the fallback has no bundled library, which is the + // PipeWire-fallback case, not a reason to search. + const DEV_ONLY: [&str; 2] = ["libdrmtap.so.0", "libdrmtap.so"]; + let is_root = unsafe { hbb_common::libc::geteuid() } == 0; + let candidates: Vec<&str> = if is_root { + vec![INSTALLED] + } else { + std::iter::once(INSTALLED).chain(DEV_ONLY).collect() + }; + unsafe { + let (lib, name) = candidates + .iter() + .find_map(|n| Library::new(*n).ok().map(|l| (l, *n)))?; + // Canonicalize the absolute candidate only: `dlopen` does not search the CWD for a bare + // soname, while `canonicalize` resolves a relative name against it. + let real = std::path::Path::new(name) + .is_absolute() + .then(|| std::fs::canonicalize(name).ok()) + .flatten(); + let version: FnVersion = *lib.get(b"drmtap_version").ok()?; + let v = version(); + let (major, minor, patch) = ((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff); + if !abi_accepted(major, minor, patch) { + let why = if major != DRMTAP_ABI_MAJOR { + "the struct layouts this build mirrors track the ABI major, so reading a \ + frame descriptor through a mismatched one would mis-decode it" + } else if minor != DRMTAP_ABI_MINOR { + "this build mirrors the struct layouts of one minor and only that one; \ + under 0.x semver the minor is the breaking axis, so an unverified minor \ + could be read at the wrong offsets. Widening it is a deliberate act, done \ + with the layouts re-checked field by field" + } else { + "it predates the split-capture API, so its only capture path converts \ + in-process, which in the root service means loading the GL stack there" + }; + let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH; + log::warn!( + "libdrmtap {name} reports v{major}.{minor}.{patch}, which this build cannot \ + use (needs ABI major {DRMTAP_ABI_MAJOR}, minor {DRMTAP_ABI_MINOR}, at least \ + v{DRMTAP_ABI_MAJOR}.{min_minor}.{min_patch}): {why}. Refusing to load; \ + falling back to PipeWire/portal." + ); + return None; + } + let open: FnOpen = *lib.get(b"drmtap_open").ok()?; + let close: FnClose = *lib.get(b"drmtap_close").ok()?; + let list_displays: FnListDisplays = *lib.get(b"drmtap_list_displays").ok()?; + let list_devices: Option = + lib.get(b"drmtap_list_devices").ok().map(|s| *s); + let grab_mapped: FnGrabMapped = *lib.get(b"drmtap_grab_mapped").ok()?; + let frame_release: FnFrameRelease = *lib.get(b"drmtap_frame_release").ok()?; + let get_cursor: FnGetCursor = *lib.get(b"drmtap_get_cursor").ok()?; + let cursor_release: FnCursorRelease = *lib.get(b"drmtap_cursor_release").ok()?; + let grab: Option = lib.get(b"drmtap_grab_desc").ok().map(|s| *s); + let open_r: Option = lib.get(b"drmtap_open_render").ok().map(|s| *s); + let conv: Option = + lib.get(b"drmtap_convert_dmabuf").ok().map(|s| *s); + let (grab_desc, open_render, convert_dmabuf) = match (grab, open_r, conv) { + (Some(g), Some(o), Some(c)) => (g, o, c), + (grab, open_r, conv) => { + let mut missing = Vec::new(); + if grab.is_none() { + missing.push("drmtap_grab_desc"); + } + if open_r.is_none() { + missing.push("drmtap_open_render"); + } + if conv.is_none() { + missing.push("drmtap_convert_dmabuf"); + } + log::warn!( + "libdrmtap {name} reports v{major}.{minor}.{patch} but does not export \ + {}: it is a stale or pre-release build, not the version it claims. \ + Refusing to load; falling back to PipeWire/portal.", + missing.join(", ") + ); + return None; + } + }; + let render_node: Option = + lib.get(b"drmtap_render_node").ok().map(|s| *s); + // Log the load only now that every required symbol resolved: this fn still returns None on a missing one. + let loaded_from = real + .as_ref() + .map_or_else(|| name.to_owned(), |p| p.display().to_string()); + if loaded_from == name { + log::info!("libdrmtap loaded: {name} (v{major}.{minor}.{patch})"); + } else { + log::info!("libdrmtap loaded: {name} -> {loaded_from} (v{major}.{minor}.{patch})"); + } + let (no_node, no_devices) = (render_node.is_none(), list_devices.is_none()); + if (minor, patch) >= (4, 15) && (no_node || no_devices) { + let missing = if no_node && no_devices { + "drmtap_render_node and drmtap_list_devices" + } else if no_node { + "drmtap_render_node" + } else { + "drmtap_list_devices" + }; + let effect = if no_node && no_devices { + "Multi-GPU display enumeration and exporting-GPU selection stay disabled." + } else if no_node { + "Exporting-GPU selection stays disabled." + } else { + "Multi-GPU display enumeration stays disabled." + }; + log::warn!( + "libdrmtap at {loaded_from} reports v{major}.{minor}.{patch} but is missing \ + {missing}: it is a stale or pre-release build. Check what the soname symlink \ + points at and remove any leftover libdrmtap.so.0* beside it. {effect}" + ); + } + Some(DrmtapLib { + _lib: lib, + open, + close, + list_displays, + list_devices, + grab_mapped, + frame_release, + get_cursor, + cursor_release, + grab_desc, + open_render, + convert_dmabuf, + render_node, + version: (major, minor, patch), + }) + } + } +} + +static DRMTAP_LIB: OnceLock> = OnceLock::new(); + +/// The loaded libdrmtap, or None if the .so (or a runtime dep) is absent or its version/exports fall outside the ABI gate. Loaded once; a failure is remembered. +pub fn get() -> Option<&'static DrmtapLib> { + DRMTAP_LIB + .get_or_init(|| { + let lib = DrmtapLib::load(); + if lib.is_none() { + log::info!("libdrmtap not available or not usable; DRM capture disabled"); + } + lib + }) + .as_ref() +} + +#[cfg(test)] +mod tests { + use super::{abi_accepted, DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, DRMTAP_MIN_MINOR_PATCH}; + + #[test] + fn abi_gate_rejects_a_library_from_before_the_split() { + // These are refused because their MINOR differs from the verified one, which is the only + // reason the gate needs. Naming the pre-split releases keeps the intent readable, but do + // not read this as the floor doing the work: see the test below. + for (minor, patch) in [(3, 3), (4, 0), (4, 8), (4, 9)] { + assert!( + !abi_accepted(DRMTAP_ABI_MAJOR, minor, patch), + "v0.{minor}.{patch} is not the verified minor and must be refused" + ); + } + } + + #[test] + fn the_patch_floor_is_currently_vacuous_and_that_is_deliberate() { + // With MIN_MINOR_PATCH.0 == DRMTAP_ABI_MINOR the floor can never reject anything: the + // minor equality already forces `(minor, patch) >= (minor, 0)`. It is kept because it is + // the mechanism that WOULD do the work the next time a floor lands mid-minor, as (4, 10) + // did for the split API. This test exists so nobody reads the pre-split test above as + // evidence that the floor is live -- if that ever matters, this assert is the tripwire. + let (floor_minor, floor_patch) = DRMTAP_MIN_MINOR_PATCH; + assert_eq!( + floor_minor, DRMTAP_ABI_MINOR, + "the floor is inside the verified minor; a floor in a DIFFERENT minor is unreachable" + ); + if floor_patch == 0 { + assert!( + abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, 0), + "patch 0 of the verified minor must be accepted while the floor is 0" + ); + } else { + assert!(!abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, floor_patch - 1)); + } + } + + #[test] + fn abi_gate_accepts_the_floor_and_later_patches_of_the_same_minor() { + let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH; + assert!(abi_accepted(DRMTAP_ABI_MAJOR, min_minor, min_patch)); + for (minor, patch) in [(DRMTAP_ABI_MINOR, min_patch + 15), (DRMTAP_ABI_MINOR, 200)] { + assert!( + abi_accepted(DRMTAP_ABI_MAJOR, minor, patch), + "v0.{minor}.{patch} is a patch of the verified minor and must be accepted" + ); + } + } + + #[test] + fn abi_gate_rejects_an_unknown_newer_minor() { + // Relative to DRMTAP_ABI_MINOR, so the next bump cannot leave this test asserting that the + // NEW verified minor must be refused -- which is what a hardcoded list did before. + let verified = DRMTAP_ABI_MINOR; + for (minor, patch) in [ + (verified - 1, 99), + (verified + 1, 0), + (verified + 1, 99), + (verified + 4, 9), + ] { + assert!( + !abi_accepted(DRMTAP_ABI_MAJOR, minor, patch), + "v0.{minor}.{patch} is an unverified minor and must be refused" + ); + } + } + + #[test] + fn abi_gate_rejects_another_major_in_both_directions() { + assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 0, 0)); + assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 99, 99)); + } +} diff --git a/libs/scrap/src/common/mod.rs b/libs/scrap/src/common/mod.rs index 2d74caa0d..1efed1176 100644 --- a/libs/scrap/src/common/mod.rs +++ b/libs/scrap/src/common/mod.rs @@ -16,6 +16,12 @@ cfg_if! { mod linux; mod wayland; mod x11; + #[cfg(all(target_os = "linux", feature = "drm"))] + pub mod drmtap_dl; + #[cfg(all(target_os = "linux", feature = "drm"))] + pub mod drm_reader; + #[cfg(all(target_os = "linux", feature = "drm"))] + pub mod drm_render; pub use self::linux::*; pub use self::wayland::set_map_err; pub use self::x11::PixelBuffer; diff --git a/src/ipc.rs b/src/ipc.rs index 188c2e467..b3abeeb55 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -3,6 +3,21 @@ mod ipc_auth; #[cfg(any(target_os = "linux", target_os = "macos"))] #[path = "ipc/fs.rs"] mod ipc_fs; +// The DRM/KMS capture producer, the `_drm` channel and its SCM_RIGHTS framing live in their own +// module, declared the same way as the other pieces of this file, so the opt-in feature adds a +// bounded, self-contained surface here instead of ~1800 lines in the middle of the shared IPC. +#[cfg(all(target_os = "linux", feature = "drm"))] +#[path = "ipc/drm.rs"] +mod ipc_drm; +// Re-exported so the paths callers already use (`crate::ipc::start_drm`, `crate::ipc::connect_drm`, +// `crate::ipc::DrmDisplayInfo`) keep working, and so the `Data` variants can name the two +// payload types. +#[cfg(all(target_os = "linux", feature = "drm"))] +pub use ipc_drm::{start_drm, DmabufDesc, DrmDisplayInfo}; +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(crate) use ipc_drm::DrmConn; +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(crate) use ipc_drm::connect_drm; #[cfg(all(feature = "flutter", feature = "plugin_framework"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -60,6 +75,9 @@ use ipc_fs::{ check_pid, ensure_secure_ipc_parent_dir, scrub_secure_ipc_parent_dir, should_scrub_parent_entries_after_check_pid, write_pid, }; +// Gated with the module that uses it, so a `drm`-less build does not carry an unused import. +#[cfg(all(target_os = "linux", feature = "drm"))] +use ipc_fs::remove_ipc_entry_via_secure_parent_fd; use parity_tokio_ipc::{ Connection as Conn, ConnectionClient as ConnClient, Endpoint, Incoming, SecurityAttributes, }; @@ -481,6 +499,51 @@ pub enum Data { ControlPermissionsRemoteModify(Option), #[cfg(target_os = "windows")] FileTransferEnabledState(Option), + // --- DRM/KMS capture (opt-in `drm` feature) over the `_drm` service-scoped channel --- + // All of the following are `cfg(all(linux, drm))`, so the drm-off IPC wire is byte-identical + // to upstream. Protocol on `_drm`: on connect the root service sends `DrmDisplayList`, the + // client replies `DrmStart{display}`, then the service streams `DrmFrame` + send_raw(BGRA) and + // `DrmCursor` + send_raw(RGBA). A frame/cursor header is ALWAYS immediately followed by exactly + // one `send_raw()` payload (the same header-then-raw pairing as `FileBlockFromCM`). This keeps + // the header extensible. The zero-copy `DrmFrameDmabuf(DmabufDesc)` sibling below carries only a + // small JSON metadata descriptor; the scanout dma-buf fd rides an SCM_RIGHTS ancillary message on + // the same `DrmConn` send (see `DrmConn::send_msg`), so it has NO trailing `send_raw()` body. + /// Client -> service: begin streaming the chosen display. + #[cfg(all(target_os = "linux", feature = "drm"))] + // `need_cpu` is set by an unprivileged consumer that could not open a render-node convert context + // (drmtap_open_render failed, e.g. no /dev/dri/renderD* access). The service then streams the + // CPU-converted `DrmFrame` path for this connection instead of a dma-buf fd the consumer cannot + // detile, so a render-node-less seat still captures instead of losing the stream. + DrmStart { display: i32, need_cpu: bool }, + /// Service -> client: the enumerated DRM displays (sent once, before frames). + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmDisplayList(Vec), + /// Service -> client: the connector topology changed mid-stream (a monitor hotplug/unplug/modeset, + /// observed by the service's udev DRM-uevent listener). Carries the freshly-enumerated list so the + /// consumer can swap its sticky positive availability cache off the hot path, WITHOUT re-probing + /// `_drm` (which would trip the enumeration restart loop). Interleaved with frames on the same + /// stream; carries no `send_raw()` body and no fd. + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmDisplaysChanged(Vec), + /// Service -> client: a frame header; the packed BGRA pixels follow via `send_raw()`. + /// CPU-fallback path (no render node, or no transferable dma-buf): pixels cross the wire. + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmFrame { width: u32, height: u32 }, + /// Service -> client: a zero-copy dma-buf frame descriptor. The scanout fd is NOT a field; when + /// `desc.has_fd` it rides an SCM_RIGHTS ancillary message on the same `DrmConn::send_msg`, and + /// there is NO trailing `send_raw()` body. The unprivileged `--server` imports the fd and does + /// the EGL detile/convert itself (see `DmabufDesc`). + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmFrameDmabuf(DmabufDesc), + /// Service -> client: a hardware-cursor header; the RGBA pixels follow via `send_raw()`. + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmCursor { + id: u64, + width: u32, + height: u32, + hotx: i32, + hoty: i32, + }, } #[tokio::main(flavor = "current_thread")] diff --git a/src/ipc/auth.rs b/src/ipc/auth.rs index 0dd43855e..89beef072 100644 --- a/src/ipc/auth.rs +++ b/src/ipc/auth.rs @@ -208,6 +208,17 @@ pub(crate) fn active_uid() -> Option { active_uid_strict() } +/// The active session uid read ONLY from the service-loop cache, never from a fresh (blocking) seat0 +/// lookup. `None` on a cache miss. For hot, latency-sensitive, fail-closed re-auth on an async runtime +/// thread (the `_drm` per-frame re-auth), where a blocking `loginctl` per frame would stall the stream. +// Gated with the feature, not just the OS: the `_drm` per-frame re-auth is its only caller, so a +// drm-off Linux build would carry it as dead code and warn about it. +#[cfg(all(target_os = "linux", feature = "drm"))] +#[inline] +pub(crate) fn active_uid_cached() -> Option { + crate::platform::linux::get_active_userid_cached() +} + #[cfg(any(target_os = "linux", target_os = "macos"))] #[inline] pub(crate) fn peer_uid_from_fd(fd: RawFd) -> Option { diff --git a/src/ipc/drm.rs b/src/ipc/drm.rs new file mode 100644 index 000000000..c2c399e6f --- /dev/null +++ b/src/ipc/drm.rs @@ -0,0 +1,1799 @@ +// The DRM/KMS capture half of the `_drm` IPC channel: types, root-service producer, framing. + +use super::ipc_auth::active_uid_cached; +use super::*; +use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd}; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct DrmDisplayInfo { + pub name: String, + pub crtc_id: u32, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub active: bool, + /// Render node of the GPU that EXPORTS this display's scanout; on a multi-GPU host auto-select + /// can bind a different GPU whose cross-vendor import then fails. Empty when the service cannot + /// name it: the consumer then auto-selects on a single-render-node host, and forces the CPU + /// path where there are several. + #[serde(default)] + pub render_node: String, + /// KMS card node (`/dev/dri/card*`) driving this display. crtc_ids are card-local, so the index + /// alone is ambiguous across cards. Empty = the single auto-detected device. + #[serde(default)] + pub device: String, +} + +/// Mirrors `scrap::drm_reader::drmtap_dmabuf_desc` except `dma_buf_fd` (never serializes — it rides +/// SCM_RIGHTS ancillary), and adds `buffer_id` (fb_id tagged with a per-connection epoch; no consumer reads it today) and `has_fd`. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DmabufDesc { + pub buffer_id: u64, + pub width: u32, + pub height: u32, + pub format: u32, + pub modifier: u64, + /// KMS framebuffer id — libdrmtap's import-once cache key. 0 disables caching for this frame. + pub fb_id: u32, + /// Used entries in `offsets`/`pitches` (1..4); 0 is treated as 1. + pub num_planes: u32, + pub offsets: [u32; 4], + pub pitches: [u32; 4], + /// DRMTAP_EOTF_* (SDR=0, PQ=2, HLG=3). PQ triggers the HDR->SDR tone-map on convert. + pub hdr_eotf: u32, + pub hdr_max_nits: u32, + /// True: the fd rides this message's SCM_RIGHTS cmsg. False: import-once cache hit for `fb_id`. + pub has_fd: bool, +} + +pub(crate) fn drm_ipc_path() -> String { + let service_path = Config::ipc_path("_service"); + let dir = std::path::Path::new(&service_path) + .parent() + .unwrap_or_else(|| std::path::Path::new("/tmp")); + dir.join("ipc_drm").to_string_lossy().into_owned() +} + +pub(crate) async fn connect_drm(ms_timeout: u64) -> ResultType { + use std::os::fd::AsRawFd; + let path = drm_ipc_path(); + let stream = timeout(ms_timeout, tokio::net::UnixStream::connect(&path)).await??; + // The producer MUST be root: a non-root peer that won a socket-path race must not be trusted to + // supply the display list, frames and an arbitrary dma-buf fd. + if peer_uid_from_fd(stream.as_raw_fd()) != Some(0) { + bail!("drm: _drm producer is not root; refusing to consume"); + } + Ok(DrmConn::new(stream)) +} + +/// Bind the `_drm` listener 0666: connectable by any local uid, authorized in `handle_drm_conn`. +fn new_drm_listener() -> ResultType { + let path = drm_ipc_path(); + let _ = ensure_secure_ipc_parent_dir(&path, "_service")?; + // NOT `std::fs::remove_file`: `unlink(2)` returns EISDIR against a directory-typed squatter and + // the bind then fails EADDRINUSE; the fd-based helper picks `AT_REMOVEDIR` (empty dirs only). + if let Err(err) = remove_ipc_entry_via_secure_parent_fd(&path) { + log::warn!("drm: could not clear a stale entry at {}: {}", &path, err); + } + let mut endpoint = Endpoint::new(path.clone()); + endpoint.set_security_attributes(SecurityAttributes::allow_everyone_create()?); + let incoming = endpoint.incoming()?; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)).map_err(|err| { + std::fs::remove_file(&path).ok(); + err + })?; + log::info!("Started drm ipc server at path: {}", &path); + Ok(incoming) +} + +enum DrmProducerMsg { + /// Enumerated displays, sent once before any frame. + Displays(Vec), + /// Zero-copy path: descriptor + scanout fd; the `OwnedFd` is closed once the send has dup'd it. + Frame { + desc: DmabufDesc, + fd: Option, + }, + /// CPU-mapped fallback (packed BGRA): consumer has no convert context (`need_cpu`), or ENOTSUP. + FrameCpu { + width: u32, + height: u32, + data: Bytes, + }, + Cursor { + id: u64, + width: u32, + height: u32, + hotx: i32, + hoty: i32, + colors: Vec, + }, +} + +struct DrmStopGuard(std::sync::Arc); +impl Drop for DrmStopGuard { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::Relaxed); + } +} + +fn dup_to_drm_conn(stream: &Connection) -> ResultType { + let raw = stream.inner.get_ref().as_raw_fd(); + // F_DUPFD_CLOEXEC, not dup(): `dup` never copies close-on-exec, and this process forks (the + // `loginctl` lookup), so an already-authorized `_drm` socket would leak into children. + let dup = unsafe { hbb_common::libc::fcntl(raw, hbb_common::libc::F_DUPFD_CLOEXEC, 0) }; + if dup < 0 { + return Err(std::io::Error::last_os_error().into()); + } + // SAFETY: `dup` is a freshly dup'd, owned fd for a connected SOCK_STREAM unix socket. + let std_stream = unsafe { std::os::unix::net::UnixStream::from_raw_fd(dup) }; + std_stream.set_nonblocking(true)?; + let tokio_stream = tokio::net::UnixStream::from_std(std_stream)?; + Ok(DrmConn::new(tokio_stream)) +} + +static DRM_DISPLAY_CACHE: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// Bumped only when a change altered `DRM_DISPLAY_CACHE`; Release orders it after the cache write. +static DRM_DISPLAY_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Displays this reader serves, plus the identity (`device:connector`) of each undriven output. +fn drm_displays_from_reader( + reader: &mut scrap::drm_reader::DrmReader, + device: &str, +) -> (Vec, Vec) { + let render_node = reader.render_node().unwrap_or_default(); + let mut undriven = Vec::new(); + let displays: Vec = reader + .displays() + .into_iter() + // Only outputs bound to a CRTC: a CONNECTED-but-unbound connector enumerates with + // `crtc_id == 0`, and `open(crtc=0)` auto-selects the FIRST ACTIVE CRTC and streams ITS frames. + .filter(|d| { + if !d.active || d.crtc_id == 0 { + undriven.push(format!("{device}:{name}", name = d.name)); + return false; + } + true + }) + .map(|d| DrmDisplayInfo { + name: d.name, + crtc_id: d.crtc_id, + x: d.x, + y: d.y, + width: d.width, + height: d.height, + active: d.active, + render_node: render_node.clone(), + device: device.to_owned(), + }) + .collect(); + (displays, undriven) +} + +/// Active displays of every DRM device + the connected-but-undriven identities, from ONE look. +fn drm_enumerate_all_displays() -> (Vec, Vec) { + if let Some(devices) = scrap::drm_reader::list_devices() { + if devices.len() > 1 { + log::info!( + "drm: {} DRM devices: {}", + devices.len(), + devices + .iter() + .map(|d| format!( + "{} ({}, render {})", + d.path, + d.display_count, + if d.render_node.is_empty() { "none" } else { &d.render_node } + )) + .collect::>() + .join(", ") + ); + } + let mut all = Vec::new(); + let mut undriven_total = Vec::new(); + let mut any_opened = false; + for dev in devices { + if let Some(mut r) = scrap::drm_reader::DrmReader::open(Some(&dev.path), 0) { + any_opened = true; + let (mut got, mut undriven) = drm_displays_from_reader(&mut r, &dev.path); + all.append(&mut got); + undriven_total.append(&mut undriven); + } else if dev.display_count == 0 { + log::debug!( + "drm: {} has no active display and did not open; cannot tell whether it has a \ + connected output that is merely switched off", + dev.path + ); + } + } + // Take this even when the list is EMPTY: the fallback re-keys identities under `device = ""`. + if any_opened { + return (all, undriven_total); + } + } + // Auto-detect alone is not enough: it picks a card that is SCANNING OUT. Measured on the T2 with + // the panel idle-disabled it binds card0 (the Touch Bar); the panel on card2 is invisible to it. + let mut all = Vec::new(); + let mut undriven_total = Vec::new(); + let mut paths: Vec = match std::fs::read_dir("/dev/dri") { + Ok(rd) => rd + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("card") && n[4..].chars().all(|c| c.is_ascii_digit())) + }) + .collect(), + Err(err) => { + log::debug!("drm: cannot read /dev/dri to enumerate cards: {err}"); + Vec::new() + } + }; + // Deterministic order, so the display list does not depend on directory order. + paths.sort(); + let n_paths = paths.len(); + for p in paths { + let Some(path) = p.to_str() else { continue }; + if let Some(mut r) = scrap::drm_reader::DrmReader::open(Some(path), 0) { + let (mut got, mut undriven) = drm_displays_from_reader(&mut r, path); + all.append(&mut got); + undriven_total.append(&mut undriven); + } + } + log::info!( + "drm: enumerated /dev/dri directly ({} card path(s)): {} active display(s), {} connected \ + but undriven", + n_paths, + all.len(), + undriven_total.len() + ); + if all.is_empty() && undriven_total.is_empty() { + if let Some(mut r) = scrap::drm_reader::DrmReader::open(None, 0) { + log::info!("drm: no card enumerated by path; falling back to the auto-detected reader"); + return drm_displays_from_reader(&mut r, ""); + } + } + (all, undriven_total) +} + +/// Connectors a wake did NOT bring back. SELF-REFUTING: an entry later seen DRIVEN is removed. +#[cfg(feature = "drm-wake")] +static DRM_WAKE_HOPELESS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +#[cfg(feature = "drm-wake")] +fn drm_wakeable_undriven(displays: &[DrmDisplayInfo], undriven: &[String]) -> Vec { + let mut hopeless = DRM_WAKE_HOPELESS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !hopeless.is_empty() { + hopeless.retain(|id| { + let driven_now = displays + .iter() + .any(|d| format!("{}:{}", d.device, d.name) == *id); + if driven_now { + log::info!("drm: {id} is scanning out after all; treating it as wakeable again"); + } + !driven_now + }); + } + undriven + .iter() + .filter(|id| !hopeless.iter().any(|h| h == *id)) + .cloned() + .collect() +} + +#[cfg(feature = "drm-wake")] +static DRM_LAST_WAKE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +#[cfg(feature = "drm-wake")] +static DRM_WAKE_UNAVAILABLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Wake config key; `enable-` is load-bearing: an absent value reads as `!= "N"`, so it defaults ON. +#[cfg(feature = "drm-wake")] +const OPTION_ENABLE_DRM_DISPLAY_WAKE: &str = "enable-drm-display-wake"; + +#[cfg(feature = "drm-wake")] +const DRM_WAKE_MIN_GAP: std::time::Duration = std::time::Duration::from_secs(20); +#[cfg(feature = "drm-wake")] +const DRM_WAKE_DEVICE_SETTLE: std::time::Duration = std::time::Duration::from_millis(400); +#[cfg(feature = "drm-wake")] +const DRM_WAKE_RECHECK_TOTAL: std::time::Duration = std::time::Duration::from_secs(3); +#[cfg(feature = "drm-wake")] +const DRM_WAKE_SETTLE_WINDOW: std::time::Duration = std::time::Duration::from_secs(5); + +/// Seconds since service start, monotonic: SystemTime would let a clock step re-open the wake gate. +#[cfg(feature = "drm-wake")] +fn drm_wake_clock_secs() -> u64 { + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + START.get_or_init(std::time::Instant::now).elapsed().as_secs() +} + +/// Look like user activity so the compositor re-enables an idle-DISABLED connector (until it does, +/// nothing scans out). Measured on a T2 greeter: one relative move restored a 2880x1800 scanout. +#[cfg(feature = "drm-wake")] +fn drm_wake_displays(reason: &str) -> bool { + use std::sync::atomic::Ordering; + + if DRM_WAKE_UNAVAILABLE.load(Ordering::Relaxed) { + return false; + } + let now = drm_wake_clock_secs(); + loop { + let last = DRM_LAST_WAKE.load(Ordering::Acquire); + if last != 0 && now.saturating_sub(last) < DRM_WAKE_MIN_GAP.as_secs() { + log::debug!( + "drm: not waking displays ({reason}): a wake {}s ago is still recent", + now.saturating_sub(last) + ); + return false; + } + if DRM_LAST_WAKE + .compare_exchange(last, now.max(1), Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + break; + } + } + + // It has to look like a MOUSE: libinput ignores a device with a single relative axis and no + // buttons. Measured: REL_X + REL_Y + BTN_LEFT woke the panel; REL_X alone did not. + let mut axes = evdev::AttributeSet::::new(); + axes.insert(evdev::RelativeAxisType::REL_X); + axes.insert(evdev::RelativeAxisType::REL_Y); + let mut keys = evdev::AttributeSet::::new(); + keys.insert(evdev::Key::BTN_LEFT); + let built = evdev::uinput::VirtualDeviceBuilder::new() + .and_then(|b| b.name("RustDesk DRM display wake").with_relative_axes(&axes)) + .and_then(|b| b.with_keys(&keys)) + .and_then(|b| b.build()); + let mut dev = match built { + Ok(d) => d, + Err(err) => { + DRM_WAKE_UNAVAILABLE.store(true, Ordering::Relaxed); + log::warn!( + "drm: cannot wake displays ({reason}): no uinput device ({err}). A compositor that \ + disabled its outputs will keep them disabled, so there is no scanout to capture \ + until something else generates input. Note input injection needs uinput too, so \ + this session cannot control the host either." + ); + return false; + } + }; + + // A FRESH uinput device is not bound yet; events written before udev binds it are lost. Measured + // back to back: with this pause the panel went `disabled -> enabled`, without it it did not. + std::thread::sleep(DRM_WAKE_DEVICE_SETTLE); + + // +1 then -1: activity with zero net displacement. emit() appends the SYN_REPORT itself. + let step = |v: i32| { + evdev::InputEvent::new( + evdev::EventType::RELATIVE, + evdev::RelativeAxisType::REL_X.0, + v, + ) + }; + let ok = dev.emit(&[step(1)]).and_then(|_| { + std::thread::sleep(std::time::Duration::from_millis(120)); + dev.emit(&[step(-1)]) + }); + if let Err(err) = ok { + log::warn!("drm: display wake ({reason}) failed to emit: {err}"); + return false; + } + log::info!("drm: no display was scanning out ({reason}); asked the compositor to wake up"); + true +} + +#[cfg(not(feature = "drm-wake"))] +fn drm_enumerate_settled(reason: &str) -> Vec { + let (displays, undriven) = drm_enumerate_all_displays(); + if !undriven.is_empty() { + log::debug!( + "drm: {} connected display(s) have no CRTC ({reason}); this build has no display wake", + undriven.len() + ); + } + displays +} + +/// Wake build: wake an undriven display and WAIT for the settled topology. The wait applies to every +/// handshake whose wake may still be in flight, not only the one whose attempt won the rate limit. +#[cfg(feature = "drm-wake")] +fn drm_enumerate_settled(reason: &str) -> Vec { + use std::sync::atomic::Ordering; + + let (displays, undriven) = drm_enumerate_all_displays(); + if !hbb_common::config::Config::get_bool_option(OPTION_ENABLE_DRM_DISPLAY_WAKE) { + if !undriven.is_empty() { + log::info!( + "drm: {} connected display(s) have no CRTC ({reason}), but the display wake is \ + disabled by configuration ({OPTION_ENABLE_DRM_DISPLAY_WAKE}=N)", + undriven.len() + ); + } + return displays; + } + let wakeable = drm_wakeable_undriven(&displays, &undriven); + if wakeable.is_empty() { + return displays; + } + let fired = drm_wake_displays(&format!( + "{reason} and {n} connected display(s) had no CRTC", + n = wakeable.len() + )); + if !fired { + if DRM_WAKE_UNAVAILABLE.load(Ordering::Relaxed) { + return displays; + } + let last = DRM_LAST_WAKE.load(Ordering::Acquire); + if last == 0 + || drm_wake_clock_secs().saturating_sub(last) > DRM_WAKE_SETTLE_WINDOW.as_secs() + { + return displays; + } + } + let before_len = displays.len(); + let deadline = std::time::Instant::now() + DRM_WAKE_RECHECK_TOTAL; + let mut cur = displays; + let mut cur_wakeable = wakeable; + while !cur_wakeable.is_empty() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(300)); + let (next, next_undriven) = drm_enumerate_all_displays(); + cur_wakeable = drm_wakeable_undriven(&next, &next_undriven); + cur = next; + } + if cur.len() > before_len { + log::info!( + "drm: {} display(s) came back after the wake ({} -> {}{})", + cur.len() - before_len, + before_len, + cur.len(), + if cur_wakeable.is_empty() { + String::new() + } else { + format!(", {} still undriven", cur_wakeable.len()) + } + ); + schedule_drm_cache_refresh(); + } + if fired && !cur_wakeable.is_empty() { + // Only the handshake that FIRED latches; a loser's baseline was taken mid-transition. + let mut hopeless = DRM_WAKE_HOPELESS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for id in &cur_wakeable { + if !hopeless.iter().any(|h| h == id) { + hopeless.push(id.clone()); + } + } + log::info!( + "drm: the wake did not bring back {list}; not asking again for {these} until {it_is} \ + seen scanning out", + list = cur_wakeable.join(", "), + these = if cur_wakeable.len() == 1 { "it" } else { "them" }, + it_is = if cur_wakeable.len() == 1 { "it is" } else { "they are" }, + ); + } + cur +} + +/// The SINGLE writer of DRM_DISPLAY_CACHE (+ DRM_DISPLAY_GENERATION), off the caller's thread and +/// SINGLE-FLIGHT: a request arriving during a run coalesces into exactly one follow-up. +fn schedule_drm_cache_refresh() { + use std::sync::atomic::{AtomicBool, Ordering}; + static RUNNING: AtomicBool = AtomicBool::new(false); + static PENDING: AtomicBool = AtomicBool::new(false); + // Ownership of RUNNING, released on every exit incl. unwind and failed spawn; re-taken mid-loop. + struct RefreshSlot(bool); + impl RefreshSlot { + fn release(&mut self) { + if self.0 { + self.0 = false; + RUNNING.store(false, Ordering::Release); + } + } + fn retake(&mut self) -> bool { + self.0 = !RUNNING.swap(true, Ordering::AcqRel); + self.0 + } + } + impl Drop for RefreshSlot { + fn drop(&mut self) { + self.release(); + } + } + // Announce a refresh is wanted before trying to run, so an active worker is guaranteed to see it. + PENDING.store(true, Ordering::Release); + if RUNNING.swap(true, Ordering::AcqRel) { + return; // a worker is already active; it will observe PENDING and refresh again + } + let mut slot = RefreshSlot(true); + let spawned = std::thread::Builder::new() + .name("drm-cache-refresh".into()) + .spawn(move || loop { + PENDING.store(false, Ordering::Release); + let fresh = std::panic::catch_unwind(drm_enumerate_all_displays) + .unwrap_or_else(|_| { + log::error!("drm: display enumeration panicked; treating as no displays"); + (Vec::new(), Vec::new()) + }) + .0; + let changed = { + let mut cache = match DRM_DISPLAY_CACHE.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + if *cache != fresh { + *cache = fresh; + true + } else { + false + } + }; + if changed { + DRM_DISPLAY_GENERATION.fetch_add(1, Ordering::Release); + log::info!("drm: display cache refreshed (topology changed)"); + } + // Exit only if no request arrived during this enumeration. The re-check after releasing + // the slot closes the lost-wakeup window (a request that set PENDING just before it). + if !PENDING.load(Ordering::Acquire) { + slot.release(); + if !PENDING.load(Ordering::Acquire) { + break; + } + if !slot.retake() { + break; // another caller re-acquired the slot; it will handle the pending refresh + } + } + }); + if let Err(err) = spawned { + log::error!("drm: could not spawn the display-cache refresh worker: {err}"); + } +} + +fn uevent_is_drm_change(msg: &[u8]) -> bool { + let mut is_drm = false; + let mut is_change = false; + for rec in msg.split(|&b| b == 0) { + if rec == b"SUBSYSTEM=drm" { + is_drm = true; + } else if rec == b"ACTION=change" || rec == b"HOTPLUG=1" { + is_change = true; + } + } + is_drm && is_change +} + +/// Refresh the display cache on DRM hotplug uevents (raw NETLINK_KOBJECT_UEVENT, no libudev). +fn drm_udev_listener() { + use hbb_common::libc; + + let sock = unsafe { + libc::socket( + libc::AF_NETLINK, + libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, + libc::NETLINK_KOBJECT_UEVENT, + ) + }; + if sock < 0 { + log::info!( + "drm: udev uevent socket unavailable ({}); hotplug refresh disabled", + std::io::Error::last_os_error() + ); + return; + } + let _owned = unsafe { OwnedFd::from_raw_fd(sock) }; + let mut addr: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + addr.nl_family = libc::AF_NETLINK as u16; + // Group 1 = kernel-originated uevents (udev re-broadcasts on group 2); pid 0 => kernel assigns. + addr.nl_groups = 1; + let rc = unsafe { + libc::bind( + sock, + &addr as *const libc::sockaddr_nl as *const libc::sockaddr, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if rc < 0 { + log::info!( + "drm: udev uevent bind failed ({}); hotplug refresh disabled", + std::io::Error::last_os_error() + ); + return; + } + log::info!("drm: udev DRM-uevent listener started"); + let mut buf = [0u8; 8192]; + loop { + // recvmsg, not recv: a local process could UNICAST a spoofed uevent to this root listener. + let mut src: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + let mut iov = libc::iovec { + iov_base: buf.as_mut_ptr() as *mut libc::c_void, + iov_len: buf.len(), + }; + let mut mhdr: libc::msghdr = unsafe { std::mem::zeroed() }; + mhdr.msg_name = &mut src as *mut libc::sockaddr_nl as *mut libc::c_void; + mhdr.msg_namelen = std::mem::size_of::() as libc::socklen_t; + mhdr.msg_iov = &mut iov; + mhdr.msg_iovlen = 1; + let n = unsafe { libc::recvmsg(sock, &mut mhdr, 0) }; + if n <= 0 { + let err = std::io::Error::last_os_error(); + if n < 0 && err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + log::info!("drm: udev uevent recv ended ({err}); hotplug refresh stopped"); + break; + } + if (mhdr.msg_namelen as usize) < std::mem::size_of::() + || src.nl_pid != 0 + || src.nl_groups == 0 + { + continue; + } + if !uevent_is_drm_change(&buf[..n as usize]) { + continue; + } + schedule_drm_cache_refresh(); + } +} + +fn drm_prewarm() { + // Re-ask, bounded: `get_display_server()` falls back to "x11" when it cannot tell (measured: + // "x11" 0.8 s into a boot on a Wayland host). `scrap::is_x11()` is the UNMEMOISED path. + const PREWARM_SESSION_RECHECK: std::time::Duration = std::time::Duration::from_secs(2); + const PREWARM_SESSION_BUDGET: std::time::Duration = std::time::Duration::from_secs(30); + let waited = std::time::Instant::now(); + while scrap::is_x11() { + if waited.elapsed() >= PREWARM_SESSION_BUDGET { + log::info!( + "drm: session still reads as X11 after {:?}; skipping the pre-warm \ + (the _drm listener still runs)", + PREWARM_SESSION_BUDGET + ); + return; + } + std::thread::sleep(PREWARM_SESSION_RECHECK); + } + let t = std::time::Instant::now(); + schedule_drm_cache_refresh(); + match scrap::drm_reader::DrmReader::open(None, 0) { + Some(mut r) => { + // grab_desc(), not grab(): exports an fd without loading libEGL into the root service. + if let Ok((fd, _desc)) = r.grab_desc() { + drop(fd); // close the warm-up fd; we only wanted to prime the device/import path + } + log::info!("drm: pre-warm framebuffer primed in {:?}", t.elapsed()); + } + None => log::info!("drm: pre-warm skipped (no reader; cache refresh requested)"), + } +} + +/// Capture producer in the ROOT `--service`: one task per consumer, reader on a worker thread. +#[tokio::main(flavor = "current_thread")] +pub async fn start_drm() { + match new_drm_listener() { + Ok(mut incoming) => { + if let Err(err) = std::thread::Builder::new() + .name("drm-prewarm".into()) + .spawn(drm_prewarm) + { + log::warn!("drm: could not spawn the pre-warm thread ({err}); skipping the warmup"); + } + if let Err(err) = std::thread::Builder::new() + .name("drm-udev".into()) + .spawn(drm_udev_listener) + { + log::warn!( + "drm: could not spawn the udev listener ({err}); a mid-session topology change \ + will not be pushed, and consumers pick it up on their next handshake" + ); + } + loop { + match incoming.next().await { + Some(Ok(stream)) => { + tokio::spawn(async move { + if let Err(err) = handle_drm_conn(Connection::new(stream)).await { + log::info!("drm ipc connection ended: {}", err); + } + }); + } + Some(Err(err)) => log::error!("Couldn't get drm client: {:?}", err), + None => { + log::error!("drm ipc listener stream ended; stopping drm producer"); + break; + } + } + } + } + Err(err) => { + log::error!("Failed to start drm ipc server: {}", err); + } + } +} + +const MAX_DRM_CONNS: usize = 8; + +fn drm_conn_admitted(prev_count: usize) -> bool { + prev_count < MAX_DRM_CONNS +} + +const MAX_DRM_AUTH_IN_FLIGHT: usize = 4; + +fn drm_auth_admitted(prev_in_flight: usize) -> bool { + prev_in_flight < MAX_DRM_AUTH_IN_FLIGHT +} + +fn drm_peer_authorized(peer_uid: Option, active_uid: Option) -> bool { + match peer_uid { + Some(0) => true, + Some(uid) => active_uid == Some(uid), + None => false, + } +} + +/// Handle one `_drm` consumer: a private worker thread owns the `!Send` reader; this task forwards. +async fn handle_drm_conn(stream: Connection) -> ResultType<()> { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Arc; + + // World-connectable socket, so the peer MUST be authorized here (this listener bypasses the + // generic `start()` accept loop). On the blocking pool: a cache miss forks `loginctl`. + static DRM_AUTH_IN_FLIGHT: AtomicUsize = AtomicUsize::new(0); + struct DrmAuthGuard; + impl Drop for DrmAuthGuard { + fn drop(&mut self) { + DRM_AUTH_IN_FLIGHT.fetch_sub(1, Ordering::SeqCst); + } + } + if !drm_auth_admitted(DRM_AUTH_IN_FLIGHT.fetch_add(1, Ordering::SeqCst)) { + DRM_AUTH_IN_FLIGHT.fetch_sub(1, Ordering::SeqCst); + // Deliberately `debug`, not `warn`: this is reachable by any local uid, so a level that + // reaches the service log on every attempt is an unbounded log-write primitive for that peer. + log::debug!("drm: too many _drm authorizations in flight; dropping this connection"); + return Ok(()); + } + let auth_guard = DrmAuthGuard; + let (stream, authorized) = tokio::task::spawn_blocking(move || { + let ok = authorize_service_scoped_ipc_connection(&stream, "_drm"); + (stream, ok) + }) + .await?; + drop(auth_guard); + if !authorized { + // Deliberately no log here: the call above already reports it -- the uid mismatch through + // `log_rejected_service_connection`, throttled to one line per 5 s, and the executable + // mismatch as a plain warn. A second, unthrottled warn here would be the same unbounded + // log-write primitive. + return Ok(()); + } + + static DRM_CONN_COUNT: AtomicUsize = AtomicUsize::new(0); + struct DrmConnGuard; + impl Drop for DrmConnGuard { + fn drop(&mut self) { + DRM_CONN_COUNT.fetch_sub(1, Ordering::SeqCst); + } + } + if !drm_conn_admitted(DRM_CONN_COUNT.fetch_add(1, Ordering::SeqCst)) { + DRM_CONN_COUNT.fetch_sub(1, Ordering::SeqCst); + log::warn!("drm: too many concurrent _drm connections (>= {MAX_DRM_CONNS}); rejecting"); + return Ok(()); + } + let _conn_guard = DrmConnGuard; + + // Re-authorized per frame below: DRM/KMS capture is NOT session-scoped, so unless a stream stops + // when the active session changes the outgoing user's --server keeps receiving the incoming + // user's screen (and the greeter in between). + let peer_uid = stream.peer_uid(); + + let mut conn = dup_to_drm_conn(&stream)?; + drop(stream); + + let (frame_tx, mut frame_rx) = tokio::sync::mpsc::channel::(2); + let (crtc_tx, crtc_rx) = std::sync::mpsc::channel::<(String, u32, bool)>(); + let stop = Arc::new(AtomicBool::new(false)); + let _stop_guard = DrmStopGuard(stop.clone()); + let worker_stop = stop.clone(); + let frames_gated = Arc::new(AtomicBool::new(false)); + let worker_gate = frames_gated.clone(); + std::thread::Builder::new() + .name("drm-capture".into()) + .spawn(move || drm_capture_worker(frame_tx, crtc_rx, worker_stop, worker_gate)) + .map_err(|err| anyhow::anyhow!("could not spawn the drm capture worker: {err}"))?; + + let displays = match frame_rx.recv().await { + Some(DrmProducerMsg::Displays(d)) => d, + _ => { + log::info!("drm: reader unavailable; closing _drm connection (client falls back)"); + return Ok(()); + } + }; + conn.send_msg(&Data::DrmDisplayList(displays.clone()), None).await?; + + let (display_idx, need_cpu) = match conn.recv_msg_timeout2(10_000).await { + Some(Ok((Data::DrmStart { display, need_cpu }, _fd))) => (display, need_cpu), + Some(Ok((_, _fd))) => { + log::info!("drm: peer sent something other than DrmStart in the handshake; closing"); + return Ok(()); + } + Some(Err(e)) => return Err(e), + None => return Ok(()), // timed out: client never chose a display + }; + // Reject crtc 0: `open(crtc=0)` auto-selects the FIRST ACTIVE CRTC and streams the WRONG monitor. + let selected = usize::try_from(display_idx) + .ok() + .and_then(|i| displays.get(i)); + let target_crtc = selected.map(|d| d.crtc_id).unwrap_or(0); + let target_device = selected.map(|d| d.device.clone()).unwrap_or_default(); + if target_crtc == 0 { + log::warn!( + "drm: client selected display {display_idx} with no bound CRTC; closing _drm (client falls back)" + ); + return Ok(()); + } + if crtc_tx.send((target_device, target_crtc, need_cpu)).is_err() { + return Ok(()); + } + + let mut seen_gen = DRM_DISPLAY_GENERATION.load(Ordering::Acquire); + const DRM_FRAME_CREDIT: i32 = 2; + let mut credit: i32 = DRM_FRAME_CREDIT; + let mut credit_since = std::time::Instant::now(); + let mut held_frame: Option = None; + loop { + conn.drain_frame_acks(&mut credit, DRM_FRAME_CREDIT)?; + // While gated the worker does not grab, so it cannot advance its own MAX_STALLED watchdog: a + // consumer that stops acking without closing the socket would otherwise hold this connection, + // its worker thread and the privileged DRM context open indefinitely. + const CREDIT_STALL: std::time::Duration = std::time::Duration::from_secs(5); + if credit > 0 { + credit_since = std::time::Instant::now(); + } else if credit_since.elapsed() > CREDIT_STALL { + log::info!("drm: consumer has not acked for {CREDIT_STALL:?}; closing _drm connection"); + break; + } + // This must NOT also require that a frame is already held: those grabs keep the held frame + // fresh (latest-wins below), so gating on "held" would pin whatever frame was in hand when + // credit ran out and ship it stale once the ack lands. + frames_gated.store(credit <= 0, Ordering::Relaxed); + let first: Option = if held_frame.is_some() && credit > 0 { + frame_rx.try_recv().ok() + } else if credit <= 0 { + const CREDIT_POLL: std::time::Duration = std::time::Duration::from_secs(1); + let waited = tokio::time::timeout(CREDIT_POLL, async { + tokio::select! { + biased; + r = conn.wait_readable() => r.map(|_| None), + m = frame_rx.recv() => Ok(Some(m)), + } + }) + .await; + match waited { + Err(_) => None, + Ok(Err(err)) => return Err(err), + Ok(Ok(None)) => None, + Ok(Ok(Some(None))) => break, + Ok(Ok(Some(Some(m)))) => Some(m), + } + } else { + match frame_rx.recv().await { + Some(f) => Some(f), + None => break, + } + }; + // Re-authorize per frame with the CACHE-ONLY active uid: a fresh lookup forks `loginctl` and + // would stall every stream on this single-threaded runtime. A miss is fail-closed for a non-root peer + // (root stays authorized; see `drm_peer_authorized`). + let peer_ok = drm_peer_authorized(peer_uid, active_uid_cached()); + if !peer_ok { + log::warn!("drm: _drm peer no longer matches the active session (or it is unknown); closing"); + break; + } + let gen = DRM_DISPLAY_GENERATION.load(Ordering::Acquire); + if gen != seen_gen { + seen_gen = gen; + let fresh = DRM_DISPLAY_CACHE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + // Send even an EMPTY list, or the consumer keeps advertising removed displays. + conn.send_msg(&Data::DrmDisplaysChanged(fresh), None).await?; + } + let mut latest_frame: Option = held_frame.take(); + let mut msg = first.or_else(|| frame_rx.try_recv().ok()); + while let Some(m) = msg.take() { + match m { + f @ (DrmProducerMsg::Frame { .. } | DrmProducerMsg::FrameCpu { .. }) => { + latest_frame = Some(f); + } + DrmProducerMsg::Cursor { + id, + width, + height, + hotx, + hoty, + colors, + } => { + conn.send_msg( + &Data::DrmCursor { + id, + width, + height, + hotx, + hoty, + }, + None, + ) + .await?; + conn.send_raw(Bytes::from(colors)).await?; + } + DrmProducerMsg::Displays(_) => {} + } + msg = frame_rx.try_recv().ok(); + } + conn.drain_frame_acks(&mut credit, DRM_FRAME_CREDIT)?; + if credit <= 0 { + held_frame = latest_frame; + continue; + } + match latest_frame { + Some(DrmProducerMsg::Frame { mut desc, fd }) => { + // Every exported frame carries its fd: the kernel can recycle an fb_id onto another + // buffer with the same geometry/modifier and this side cannot see the dma-buf inode + // that would tell the difference, so eliding it can serve a stale EGLImage. libdrmtap's + // import cache keys on fb_id AND inode, and can only re-import when handed a real fd. + let send_fd = fd.is_some(); + desc.has_fd = send_fd; + let borrowed = if send_fd { fd.as_ref().map(|f| f.as_fd()) } else { None }; + conn.send_msg(&Data::DrmFrameDmabuf(desc), borrowed).await?; + credit -= 1; // one frame in flight until the consumer acks it + // `fd` (OwnedFd) is closed here whether or not it was attached (the cmsg dup'd it + // into the peer), which bounds our fd usage to ~1 in flight per frame. + } + Some(DrmProducerMsg::FrameCpu { + width, + height, + data, + }) => { + conn.send_msg(&Data::DrmFrame { width, height }, None).await?; + conn.send_raw(data).await?; + credit -= 1; // one frame in flight until the consumer acks it + } + _ => {} + } + } + Ok(()) +} + +fn drm_capture_worker( + frame_tx: tokio::sync::mpsc::Sender, + crtc_rx: std::sync::mpsc::Receiver<(String, u32, bool)>, + stop: std::sync::Arc, + frames_gated: std::sync::Arc, +) { + use std::sync::atomic::Ordering; + use std::time::Duration; + const FRAME_INTERVAL: Duration = Duration::from_millis(33); + // Bound continuous no-frame (WouldBlock) time so a wedged device ends the stream (~5 s). + const MAX_STALLED: u32 = 150; + + let t_conn = std::time::Instant::now(); + + // Enumerate FRESH rather than serve the cache: a cached display may no longer be driven. + let displays = drm_enumerate_settled("a consumer connected"); + if frame_tx + .blocking_send(DrmProducerMsg::Displays(displays)) + .is_err() + { + return; + } + + let (target_device, target_crtc, need_cpu) = match crtc_rx.recv() { + Ok(c) => c, + Err(_) => return, + }; + let device_arg = if target_device.is_empty() { + None + } else { + Some(target_device.as_str()) + }; + let t_open = std::time::Instant::now(); + let mut reader = match scrap::drm_reader::DrmReader::open(device_arg, target_crtc) { + Some(r) => r, + None => { + log::warn!( + "drm: failed to open crtc {target_crtc} on {}; closing _drm connection", + if target_device.is_empty() { "auto" } else { &target_device } + ); + schedule_drm_cache_refresh(); + return; + } + }; + schedule_drm_cache_refresh(); + log::debug!( + "drm: capture reader for crtc {target_crtc} opened in {:?}", + t_open.elapsed() + ); + + static DRM_CONN_EPOCH: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let conn_epoch = DRM_CONN_EPOCH.fetch_add(1, Ordering::Relaxed); + + let mut use_dmabuf = !need_cpu; + + let mut last_cursor_id: u64 = 0; + let mut stalled: u32 = 0; + let mut logged_first = false; + while !stop.load(Ordering::Relaxed) { + let grabbed: Option> = if frames_gated.load(Ordering::Relaxed) + { + // `stalled` is left untouched because the device is healthy -- the task bounds this + // state itself (CREDIT_STALL) since our watchdog cannot advance. + None + } else if use_dmabuf { + Some(match reader.grab_desc() { + Ok((fd, d)) => Ok(DrmProducerMsg::Frame { + desc: DmabufDesc { + buffer_id: (d.fb_id as u64) | ((conn_epoch as u64) << 32), + width: d.width, + height: d.height, + format: d.format, + modifier: d.modifier, + fb_id: d.fb_id, + num_planes: d.num_planes, + offsets: d.offsets, + pitches: d.pitches, + hdr_eotf: d.hdr_eotf, + hdr_max_nits: d.hdr_max_nits, + has_fd: true, // every exported frame carries its fd; see the send below + }, + fd: Some(fd), + }), + Err(err) => Err(err), + }) + } else { + Some(match reader.grab() { + Ok((buf, w, h)) => Ok(DrmProducerMsg::FrameCpu { + width: w as u32, + height: h as u32, + data: Bytes::copy_from_slice(buf), + }), + Err(err) => Err(err), + }) + }; + match grabbed { + None => {} + Some(Ok(msg)) => { + stalled = 0; + if !logged_first { + logged_first = true; + log::debug!( + "drm: first frame for crtc {target_crtc} in {:?} ({} path)", + t_conn.elapsed(), + if use_dmabuf { "dma-buf" } else { "cpu" } + ); + } + if frame_tx.blocking_send(msg).is_err() { + break; + } + } + Some(Err(err)) if err.kind() == std::io::ErrorKind::WouldBlock => { + stalled += 1; + if stalled > MAX_STALLED { + log::info!("drm: capture stalled (no frame); closing _drm connection"); + break; + } + std::thread::sleep(FRAME_INTERVAL); + continue; + } + Some(Err(err)) if use_dmabuf && err.kind() == std::io::ErrorKind::Unsupported => { + log::warn!( + "drm: grab_desc unsupported ({err}); switching to CPU-mapped fallback for this connection" + ); + use_dmabuf = false; + logged_first = false; + // The stall counter measured the abandoned path; give the fallback the whole budget. + stalled = 0; + continue; + } + Some(Err(err)) => { + log::warn!("drm: capture error: {err}; closing _drm connection"); + break; + } + } + + // Ship the cursor shape only when it changes (id is a content hash or the hidden sentinel). + if let Some(c) = reader.cursor() { + if c.id != last_cursor_id { + last_cursor_id = c.id; + if frame_tx + .blocking_send(DrmProducerMsg::Cursor { + id: c.id, + width: c.width, + height: c.height, + hotx: c.hotx, + hoty: c.hoty, + colors: c.colors, + }) + .is_err() + { + break; + } + } + } + + std::thread::sleep(FRAME_INTERVAL); + } +} + +/// Ancillary-fd transport for `_drm`: `Framed`/`BytesCodec` cannot carry an SCM_RIGHTS cmsg, so the +/// messages and raw bodies use a 4-byte big-endian length + payload, with any fd bound to the first + /// byte. The reverse-direction frame acks are bare bytes, not framed. +pub(crate) struct DrmConn { + stream: tokio::net::UnixStream, + read_buf: Vec, + /// Set once the current read consumed a byte: a spurious `readable()` vs a mid-frame stall. + consumed: bool, +} + +const MAX_DRM_JSON_BYTES: usize = 8 * 1024 * 1024; +const DRM_BODY_TIMEOUT_MS: u64 = 5_000; +const DRM_SEND_TIMEOUT_MS: u64 = 5_000; + +const MAX_DRM_RAW_BYTES: usize = 512 * 1024 * 1024; +/// `CMSG_SPACE(sizeof(int))` is 24 bytes on our targets; 64 gives headroom and the `align(8)` +/// matches `cmsghdr` alignment. +const DRM_CMSG_CAP: usize = 64; + +/// Aligned storage for the SCM_RIGHTS control buffer (`msg_control` must be `cmsghdr`-aligned). +#[repr(align(8))] +struct DrmCmsgBuf([u8; DRM_CMSG_CAP]); + +/// One non-blocking `sendmsg`; the cmsg is attached ONLY when a fd is present (-1 fails the call). +/// SAFETY: `fd` a valid open socket fd, `buf` a readable slice, `pass_fd` (if any) a valid open fd. +unsafe fn drm_sendmsg(fd: RawFd, buf: &[u8], pass_fd: Option) -> std::io::Result { + use hbb_common::libc; + let mut iov = libc::iovec { + iov_base: buf.as_ptr() as *mut libc::c_void, + iov_len: buf.len(), + }; + let mut msg: libc::msghdr = std::mem::zeroed(); + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + let mut cbuf = DrmCmsgBuf([0u8; DRM_CMSG_CAP]); + if let Some(sfd) = pass_fd { + msg.msg_control = cbuf.0.as_mut_ptr() as *mut libc::c_void; + msg.msg_controllen = libc::CMSG_SPACE(std::mem::size_of::() as u32) as _; + let cmsg = libc::CMSG_FIRSTHDR(&msg); + if cmsg.is_null() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "drm: CMSG_FIRSTHDR null", + )); + } + (*cmsg).cmsg_level = libc::SOL_SOCKET; + (*cmsg).cmsg_type = libc::SCM_RIGHTS; + (*cmsg).cmsg_len = libc::CMSG_LEN(std::mem::size_of::() as u32) as _; + let sfd_c: libc::c_int = sfd; + std::ptr::copy_nonoverlapping( + &sfd_c as *const libc::c_int as *const u8, + libc::CMSG_DATA(cmsg), + std::mem::size_of::(), + ); + } + let n = libc::sendmsg(fd, &msg, libc::MSG_NOSIGNAL); + if n < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(n as usize) + } +} + +/// One non-blocking `recvmsg`: keeps at most one SCM_RIGHTS fd (surplus closed), rejects MSG_CTRUNC. +/// SAFETY: `fd` must be a valid open socket fd; `buf` a valid writable slice. +unsafe fn drm_recvmsg(fd: RawFd, buf: &mut [u8]) -> std::io::Result<(usize, Option)> { + use hbb_common::libc; + let mut iov = libc::iovec { + iov_base: buf.as_mut_ptr() as *mut libc::c_void, + iov_len: buf.len(), + }; + let mut cbuf = DrmCmsgBuf([0u8; DRM_CMSG_CAP]); + let mut msg: libc::msghdr = std::mem::zeroed(); + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + msg.msg_control = cbuf.0.as_mut_ptr() as *mut libc::c_void; + msg.msg_controllen = cbuf.0.len() as _; + let n = libc::recvmsg(fd, &mut msg, libc::MSG_CMSG_CLOEXEC); + if n < 0 { + return Err(std::io::Error::last_os_error()); + } + let mut got: Option = None; + let mut cmsg = libc::CMSG_FIRSTHDR(&msg); + while !cmsg.is_null() { + if (*cmsg).cmsg_level == libc::SOL_SOCKET && (*cmsg).cmsg_type == libc::SCM_RIGHTS { + let data = libc::CMSG_DATA(cmsg); + let hdr = libc::CMSG_LEN(0) as usize; + let payload = ((*cmsg).cmsg_len as usize).saturating_sub(hdr); + let count = payload / std::mem::size_of::(); + for i in 0..count { + let mut rawfd: libc::c_int = -1; + std::ptr::copy_nonoverlapping( + data.add(i * std::mem::size_of::()), + &mut rawfd as *mut libc::c_int as *mut u8, + std::mem::size_of::(), + ); + if rawfd >= 0 { + let owned = OwnedFd::from_raw_fd(rawfd); + if got.is_none() { + got = Some(owned); + } // else: surplus fd, dropped here -> closed + } + } + } + cmsg = libc::CMSG_NXTHDR(&msg, cmsg); + } + if msg.msg_flags & libc::MSG_CTRUNC != 0 { + drop(got); + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "drm: truncated SCM_RIGHTS control message (MSG_CTRUNC)", + )); + } + Ok((n as usize, got)) +} + +async fn drm_write_all( + stream: &tokio::net::UnixStream, + mut buf: &[u8], + mut pass_fd: Option, +) -> ResultType<()> { + // ONE deadline for the whole write: arming it per readiness wait lets a dripping peer re-arm it. + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS); + while !buf.is_empty() { + match tokio::time::timeout_at(deadline, stream.writable()).await { + Ok(r) => r?, + Err(_) => bail!( + "drm: peer did not accept the remaining {} byte(s) within {DRM_SEND_TIMEOUT_MS}ms; closing", + buf.len() + ), + } + let raw = stream.as_raw_fd(); + let chunk = buf; + let fd_now = pass_fd; + match stream.try_io(tokio::io::Interest::WRITABLE, || unsafe { + drm_sendmsg(raw, chunk, fd_now) + }) { + Ok(0) => bail!("drm: socket write returned 0 (peer closed)"), + Ok(n) => { + pass_fd = None; // ancillary delivered with these bytes; do not re-send it + buf = &buf[n..]; + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) => return Err(e.into()), + } + } + Ok(()) +} + +async fn drm_send_frame( + stream: &tokio::net::UnixStream, + payload: &[u8], + pass_fd: Option, +) -> ResultType<()> { + if payload.len() > u32::MAX as usize { + bail!("drm: frame too large ({} bytes)", payload.len()); + } + let prefix = (payload.len() as u32).to_be_bytes(); + drm_write_all(stream, &prefix, pass_fd).await?; + drm_write_all(stream, payload, None).await?; + Ok(()) +} + +async fn drm_read_full( + stream: &tokio::net::UnixStream, + buf: &mut [u8], + want_cmsg: bool, + progress: &mut bool, +) -> ResultType> { + use hbb_common::libc; + let mut off = 0usize; + let mut got: Option = None; + while off < buf.len() { + stream.readable().await?; + let raw = stream.as_raw_fd(); + let use_cmsg = want_cmsg && got.is_none(); + let n = { + let dst: &mut [u8] = &mut buf[off..]; + match stream.try_io(tokio::io::Interest::READABLE, move || unsafe { + if use_cmsg { + drm_recvmsg(raw, dst) + } else { + let m = libc::read(raw, dst.as_mut_ptr() as *mut libc::c_void, dst.len()); + if m < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok((m as usize, None)) + } + } + }) { + Ok((0, _fd)) => bail!("drm: socket closed by peer"), + Ok((m, fd)) => { + if let Some(f) = fd { + if got.is_none() { + got = Some(f); + } + } + m + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) => return Err(e.into()), + } + }; + // Any byte off the socket commits us to this frame: a cancellation cannot be re-polled. + if n > 0 { + *progress = true; + } + off += n; + } + Ok(got) +} + +impl DrmConn { + pub fn new(stream: tokio::net::UnixStream) -> Self { + Self { + stream, + read_buf: Vec::new(), + consumed: false, + } + } + + pub async fn send_msg(&mut self, data: &Data, fd: Option>) -> ResultType<()> { + let payload = serde_json::to_vec(data)?; + let pass_fd = fd.map(|f| f.as_raw_fd()); + drm_send_frame(&self.stream, &payload, pass_fd).await + } + + pub async fn send_frame_ack(&self) -> ResultType<()> { + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS); + loop { + match tokio::time::timeout_at(deadline, self.stream.writable()).await { + Ok(r) => r?, + Err(_) => bail!( + "drm: _drm frame-ack was not accepted within {DRM_SEND_TIMEOUT_MS}ms; closing" + ), + } + match self.stream.try_write(&[1u8]) { + Ok(n) if n > 0 => return Ok(()), + Ok(_) => bail!("drm: _drm frame-ack write returned 0 (peer closed)"), + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) => return Err(e.into()), + } + } + } + + pub fn drain_frame_acks(&self, credit: &mut i32, max: i32) -> ResultType<()> { + let mut buf = [0u8; 64]; + // BOUNDED: "until WouldBlock" is the peer's promise; a continuous writer would pin us. + const MAX_ACK_READS: usize = 64; + for _ in 0..MAX_ACK_READS { + match self.stream.try_read(&mut buf) { + Ok(0) => bail!("drm: _drm frame-ack peer closed"), + Ok(n) => { + *credit = (*credit + n as i32).min(max); + if *credit >= max { + return Ok(()); + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return Ok(()), + Err(e) => return Err(e.into()), + } + } + Ok(()) + } + + pub async fn wait_readable(&self) -> ResultType<()> { + self.stream.readable().await?; + Ok(()) + } + + pub async fn recv_msg(&mut self) -> ResultType<(Data, Option)> { + self.consumed = false; + let mut prefix = [0u8; 4]; + let fd = drm_read_full(&self.stream, &mut prefix, true, &mut self.consumed).await?; + let len = u32::from_be_bytes(prefix) as usize; + if len > MAX_DRM_JSON_BYTES { + bail!("drm: message length {len} exceeds cap {MAX_DRM_JSON_BYTES}"); + } + if self.read_buf.len() < len { + self.read_buf.resize(len, 0); + } + drm_read_full(&self.stream, &mut self.read_buf[..len], false, &mut self.consumed).await?; + let data: Data = serde_json::from_slice(&self.read_buf[..len])?; + Ok((data, fd)) + } + + /// Cancel-safe timeout wrapper around `recv_msg`. `None` = nothing consumed, so re-polling is + /// safe; past the first byte the frame is committed and an overrun is a hard error. + pub async fn recv_msg_timeout2( + &mut self, + ms_timeout: u64, + ) -> Option)>> { + let ready = timeout(ms_timeout, self.stream.readable()).await; + match ready { + Err(_) => None, // no frame started: clean boundary, caller re-checks `stop` + Ok(Err(e)) => Some(Err(e.into())), + Ok(Ok(())) => match timeout(ms_timeout, self.recv_msg()).await { + Ok(res) => Some(res), + Err(_) if self.consumed => Some(Err(anyhow::anyhow!( + "drm: frame body stalled past {ms_timeout}ms after first byte; closing" + ))), + Err(_) => None, + }, + } + } + + pub async fn send_raw(&mut self, data: Bytes) -> ResultType<()> { + drm_send_frame(&self.stream, &data, None).await + } + + pub async fn next_raw_into(&mut self, out: &mut Vec) -> ResultType<()> { + match timeout(DRM_BODY_TIMEOUT_MS, self.next_raw_into_unbounded(out)).await { + Ok(res) => res, + Err(_) => bail!( + "drm: raw body did not arrive within {DRM_BODY_TIMEOUT_MS}ms of its header; closing" + ), + } + } + + async fn next_raw_into_unbounded(&mut self, out: &mut Vec) -> ResultType<()> { + let mut prefix = [0u8; 4]; + if drm_read_full(&self.stream, &mut prefix, true, &mut self.consumed) + .await? + .is_some() + { + log::warn!("drm: unexpected fd on a raw-body frame; dropping"); + } + let len = u32::from_be_bytes(prefix) as usize; + if len > MAX_DRM_RAW_BYTES { + bail!("drm: raw body length {len} exceeds cap {MAX_DRM_RAW_BYTES}"); + } + out.resize(len, 0); + drm_read_full(&self.stream, &mut out[..], false, &mut self.consumed).await?; + Ok(()) + } +} + +#[cfg(test)] +mod drm_conn_tests { + use super::*; + use hbb_common::libc; + use hbb_common::tokio::{self, io::AsyncWriteExt}; + use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd}; + + // Added to the wire later: an older peer's message must still decode. + #[test] + fn drm_display_info_decodes_without_render_node() { + let legacy = r#"{"name":"DP-1","crtc_id":386,"x":0,"y":0, + "width":3840,"height":2160,"active":true}"#; + let info: DrmDisplayInfo = + serde_json::from_str(legacy).expect("a pre-render_node payload must still decode"); + assert_eq!(info.name, "DP-1"); + assert_eq!(info.crtc_id, 386); + assert!(info.render_node.is_empty(), "missing node; the consumer auto-selects only where there is one render node"); + assert!(info.device.is_empty(), "missing device means auto-detect"); + + let current = DrmDisplayInfo { + name: "DP-1".to_owned(), + crtc_id: 386, + x: 0, + y: 0, + width: 3840, + height: 2160, + active: true, + render_node: "/dev/dri/renderD129".to_owned(), + device: "/dev/dri/card2".to_owned(), + }; + let wire = serde_json::to_vec(¤t).unwrap(); + let back: DrmDisplayInfo = serde_json::from_slice(&wire).unwrap(); + assert_eq!(back, current); + } + + fn pipe() -> (OwnedFd, OwnedFd) { + let mut fds = [0 as libc::c_int; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe() failed"); + unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) } + } + + unsafe fn send_with_fds(sock: libc::c_int, data: &[u8], fds: &[libc::c_int]) -> isize { + let mut iov = libc::iovec { + iov_base: data.as_ptr() as *mut libc::c_void, + iov_len: data.len(), + }; + let fdbytes = fds.len() * std::mem::size_of::(); + let space = libc::CMSG_SPACE(fdbytes as u32) as usize; + let mut cbuf = vec![0u8; space]; + let mut msg: libc::msghdr = std::mem::zeroed(); + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + msg.msg_control = cbuf.as_mut_ptr() as *mut libc::c_void; + msg.msg_controllen = space as _; + let cmsg = libc::CMSG_FIRSTHDR(&msg); + (*cmsg).cmsg_level = libc::SOL_SOCKET; + (*cmsg).cmsg_type = libc::SCM_RIGHTS; + (*cmsg).cmsg_len = libc::CMSG_LEN(fdbytes as u32) as _; + std::ptr::copy_nonoverlapping(fds.as_ptr() as *const u8, libc::CMSG_DATA(cmsg), fdbytes); + libc::sendmsg(sock, &msg, 0) + } + + #[tokio::test] + async fn roundtrip_msg_no_fd() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut tx = DrmConn::new(a); + let mut rx = DrmConn::new(b); + tx.send_msg(&Data::DrmFrame { width: 1920, height: 1080 }, None) + .await + .unwrap(); + let (data, fd) = rx.recv_msg().await.unwrap(); + assert!(matches!( + data, + Data::DrmFrame { + width: 1920, + height: 1080 + } + )); + assert!(fd.is_none(), "no fd was sent, none must be reported"); + } + + #[tokio::test] + async fn roundtrip_msg_with_fd_identity() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut tx = DrmConn::new(a); + let mut rx = DrmConn::new(b); + let (rd, wr) = pipe(); + tx.send_msg(&Data::DrmFrame { width: 4, height: 4 }, Some(rd.as_fd())) + .await + .unwrap(); + let (_data, fd) = rx.recv_msg().await.unwrap(); + let recv_fd = fd.expect("an fd was attached, it must be received"); + let sentinel = [0xABu8]; + assert_eq!( + unsafe { libc::write(wr.as_raw_fd(), sentinel.as_ptr() as *const libc::c_void, 1) }, + 1 + ); + let mut got = [0u8; 1]; + assert_eq!( + unsafe { libc::read(recv_fd.as_raw_fd(), got.as_mut_ptr() as *mut libc::c_void, 1) }, + 1 + ); + assert_eq!(got[0], 0xAB, "received fd must be the same pipe"); + } + + #[tokio::test] + async fn roundtrip_raw_body() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut tx = DrmConn::new(a); + let mut rx = DrmConn::new(b); + let body = Bytes::from(vec![7u8; 5000]); + tx.send_raw(body.clone()).await.unwrap(); + let mut got = Vec::new(); + rx.next_raw_into(&mut got).await.unwrap(); + assert_eq!(&got[..], &body[..]); + let short = Bytes::from(vec![9u8; 10]); + tx.send_raw(short.clone()).await.unwrap(); + rx.next_raw_into(&mut got).await.unwrap(); + assert_eq!(&got[..], &short[..]); + } + + #[tokio::test] + async fn rejects_oversized_length_prefix() { + let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + let bogus = (MAX_DRM_JSON_BYTES as u32 + 1).to_be_bytes(); + a.write_all(&bogus).await.unwrap(); + let err = rx + .recv_msg() + .await + .err() + .expect("a length past the cap must be rejected"); + assert!( + err.to_string().contains("exceeds cap"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn a_body_that_never_arrives_times_out() { + let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + a.write_all(&10u32.to_be_bytes()).await.unwrap(); + let mut got = Vec::new(); + let err = rx + .next_raw_into(&mut got) + .await + .err() + .expect("a body that never arrives must time out"); + assert!( + err.to_string().contains("did not arrive"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn a_dripping_peer_cannot_re_arm_the_send_deadline() { + use tokio::io::AsyncReadExt; + let (mut reader, writer) = tokio::net::UnixStream::pair().unwrap(); + let payload = vec![0u8; 32 * 1024 * 1024]; + // Measured: 1 KiB drains do not re-assert POLLOUT; 64 KiB does, which separates the forms. + let drip = tokio::spawn(async move { + let mut sink = vec![0u8; 64 * 1024]; + loop { + if reader.read(&mut sink).await.unwrap_or(0) == 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + }); + let started = std::time::Instant::now(); + let outcome = tokio::time::timeout( + std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS * 4), + drm_write_all(&writer, &payload, None), + ) + .await; + drip.abort(); + let inner = outcome.expect( + "the send deadline did not fire: the budget is being re-armed per readiness wait", + ); + let err = inner.err().expect("a dripping peer must not complete the write"); + assert!( + err.to_string().contains("did not accept the remaining"), + "unexpected error: {err}" + ); + assert!( + started.elapsed() < std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS * 3), + "took {:?}, which is not the send deadline firing", + started.elapsed() + ); + } + + #[tokio::test] + async fn surplus_fds_keep_only_the_first() { + let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + let (rd, wr) = pipe(); + let (rd2, _wr2) = pipe(); + let payload = serde_json::to_vec(&Data::DrmFrame { + width: 8, + height: 8, + }) + .unwrap(); + let prefix = (payload.len() as u32).to_be_bytes(); + let n = unsafe { send_with_fds(a.as_raw_fd(), &prefix, &[rd.as_raw_fd(), rd2.as_raw_fd()]) }; + assert!(n >= 0, "sendmsg failed: {}", std::io::Error::last_os_error()); + a.write_all(&payload).await.unwrap(); + let (data, fd) = rx.recv_msg().await.unwrap(); + assert!(matches!( + data, + Data::DrmFrame { + width: 8, + height: 8 + } + )); + let kept = fd.expect("the first surplus fd must be kept"); + let sentinel = [0x5Au8]; + assert_eq!( + unsafe { libc::write(wr.as_raw_fd(), sentinel.as_ptr() as *const libc::c_void, 1) }, + 1 + ); + let mut got = [0u8; 1]; + assert_eq!( + unsafe { libc::read(kept.as_raw_fd(), got.as_mut_ptr() as *mut libc::c_void, 1) }, + 1 + ); + assert_eq!(got[0], 0x5A, "the kept fd must be the FIRST one sent"); + } + + // 16 fds need CMSG_LEN(64)=80 > the 64-byte DRM_CMSG_CAP, so the kernel sets MSG_CTRUNC. + #[tokio::test] + async fn rejects_truncated_control_message() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + let (rd, _wr) = pipe(); + let dups: Vec = (0..16).map(|_| rd.try_clone().unwrap()).collect(); + let fds: Vec = dups.iter().map(|f| f.as_raw_fd()).collect(); + let prefix = 0u32.to_be_bytes(); // the fds ride the prefix read; CTRUNC fires before any body + let n = unsafe { send_with_fds(a.as_raw_fd(), &prefix, &fds) }; + assert!(n >= 0, "sendmsg failed: {}", std::io::Error::last_os_error()); + let err = rx + .recv_msg() + .await + .err() + .expect("a truncated control message must be rejected"); + let msg = err.to_string().to_lowercase(); + assert!( + msg.contains("truncat") || msg.contains("ctrunc"), + "unexpected error: {err}" + ); + } + + #[test] + fn peer_uid_from_fd_reads_socket_peer() { + let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap(); + let euid = unsafe { libc::geteuid() }; + assert_eq!(peer_uid_from_fd(a.as_raw_fd()), Some(euid)); + } + + #[test] + fn drm_peer_authorized_matrix() { + assert!(drm_peer_authorized(Some(0), Some(1000))); + assert!(drm_peer_authorized(Some(0), None)); + assert!(drm_peer_authorized(Some(1000), Some(1000))); + assert!(!drm_peer_authorized(Some(1000), Some(1001))); + assert!(!drm_peer_authorized(Some(1000), None)); + assert!(!drm_peer_authorized(None, Some(1000))); + assert!(!drm_peer_authorized(None, None)); + } + + #[test] + fn accept_time_exe_match_accepts_only_our_own_executable() { + let me = std::process::id(); + assert!( + super::ipc_auth::ensure_peer_executable_matches_current_by_pid_opt(Some(me), "_drm").is_ok(), + "the test process must match its own executable" + ); + + let mut other = std::process::Command::new("/bin/sleep") + .arg("30") + .spawn() + .expect("/bin/sleep should be spawnable in the test environment"); + // Until the child finishes exec'ing, /proc//exe still points at OUR binary. + let ours = std::fs::read_link(format!("/proc/{me}/exe")).ok(); + let peer_link = format!("/proc/{}/exe", other.id()); + let mut exec_done = false; + for _ in 0..200 { + match std::fs::read_link(&peer_link) { + Ok(p) if Some(&p) != ours.as_ref() => { + exec_done = true; + break; + } + _ => std::thread::sleep(std::time::Duration::from_millis(10)), + } + } + let res = if exec_done { + super::ipc_auth::ensure_peer_executable_matches_current_by_pid_opt(Some(other.id()), "_drm") + } else { + Err(anyhow::anyhow!("child never exec'd; nothing was tested")) + }; + let _ = other.kill(); + let _ = other.wait(); + assert!(exec_done, "the spawned child never exec'd, so the negative case was not exercised"); + assert!( + res.is_err(), + "a peer running another executable must be rejected, got {res:?}" + ); + + assert!(super::ipc_auth::ensure_peer_executable_matches_current_by_pid_opt(None, "_drm").is_err()); + } + + #[test] + fn drm_conn_admission_bound() { + assert!(drm_conn_admitted(0)); + assert!(drm_conn_admitted(MAX_DRM_CONNS - 1)); // last admitted slot + assert!(!drm_conn_admitted(MAX_DRM_CONNS)); // cap reached -> rejected + assert!(!drm_conn_admitted(MAX_DRM_CONNS + 5)); // over cap -> rejected + } + + #[test] + fn drm_auth_admission_bound() { + assert!(drm_auth_admitted(0)); + assert!(drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT - 1)); // last admitted slot + assert!(!drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT)); // cap reached -> rejected + assert!(!drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT + 5)); // over cap -> rejected + assert!( + MAX_DRM_AUTH_IN_FLIGHT <= MAX_DRM_CONNS, + "the pre-auth bound must not be looser than the connection cap" + ); + } +} diff --git a/src/ipc/fs.rs b/src/ipc/fs.rs index e0157f3a9..2472ecc83 100644 --- a/src/ipc/fs.rs +++ b/src/ipc/fs.rs @@ -164,9 +164,25 @@ fn scrub_preexisting_ipc_parent_entries( Ok(()) } -fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> { - let path = config::Config::ipc_path(postfix); - let parent_dir = Path::new(&path) +/// Remove one entry from the IPC parent directory through a no-follow fd on that directory. +/// +/// Prefer this over `std::fs::remove_file` for anything about to be bound: `remove_file` is +/// `unlink(2)`, which returns EISDIR against a directory-typed squatter and leaves it in place, +/// and the bind that follows then fails EADDRINUSE. `remove_parent_entry_via_fd` fstats the +/// entry first and picks `AT_REMOVEDIR` when it needs to. +/// +/// `AT_REMOVEDIR` is `rmdir(2)`, so the directory case this closes is the EMPTY one; a non-empty +/// squatter still yields ENOTEMPTY and still blocks the bind that follows. That is deliberate, and +/// the "obvious" fix is worse than the bug: removing it recursively would be root deleting a tree +/// an unprivileged process planted. What the caller gains there is a named error to log ahead of +/// the bind's own failure, not a successful bind. +pub(crate) fn remove_ipc_entry_via_secure_parent_fd(path: &str) -> ResultType<()> { + let entry_name = Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))? + .to_owned(); + let parent_dir = Path::new(path) .parent() .ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?; let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?; @@ -179,8 +195,8 @@ fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> { return Err(Error::new( open_err.kind(), format!( - "failed to open ipc parent dir for stale socket cleanup (no-follow): postfix={}, parent={}, err={}", - postfix, + "failed to open ipc parent dir for stale socket cleanup (no-follow): path={}, parent={}, err={}", + path, parent_dir.display(), open_err ), @@ -189,7 +205,11 @@ fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> { } }; let _fd_guard = FdGuard(fd); - remove_parent_entry_via_fd(fd, parent_dir, &format!("ipc{}", postfix)) + remove_parent_entry_via_fd(fd, parent_dir, &entry_name) +} + +fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> { + remove_ipc_entry_via_secure_parent_fd(&config::Config::ipc_path(postfix)) } // Purpose: @@ -686,6 +706,64 @@ pub(crate) fn should_scrub_parent_entries_after_check_pid( #[cfg(test)] mod tests { + // Pins the HELPER's contract, which is all `new_drm_listener` consists of at that line -- not + // the call site itself. Binding the real `/tmp/-service/ipc_drm` from a test would collide + // with a live root service, so "the listener still calls this" is not covered here. + #[test] + fn test_remove_ipc_entry_via_secure_parent_fd_clears_an_empty_directory_squatter() { + let unique = format!( + "rustdesk-ipc-entry-remove-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + let squatter = base.join("ipc_drm"); + std::fs::create_dir(&squatter).unwrap(); + + // Positive control for the defect this closes: `remove_file` is `unlink(2)` and cannot + // remove a directory. That is why the listener could not clear one, and then failed to + // bind over it. Without this line a passing test would prove nothing. + assert!( + std::fs::remove_file(&squatter).is_err(), + "remove_file must fail on a directory, or this test is vacuous" + ); + assert!(squatter.is_dir()); + + super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()).unwrap(); + assert!( + !squatter.exists(), + "the fd-based removal picks AT_REMOVEDIR and clears it" + ); + + // Idempotent: this runs before every bind, so a path that is already gone is not an error. + super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()).unwrap(); + + // The ORDINARY case, and the one the listener hits on every restart: a stale socket left by + // the previous run, i.e. a regular file. Covered here because the other file-removal test + // goes through `remove_parent_entry_via_fd` and the postfix path, not this entry point. + std::fs::write(&squatter, b"stale").unwrap(); + super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()).unwrap(); + assert!(!squatter.exists(), "a stale regular file is cleared too"); + + // And the documented limit, pinned so the doc cannot drift: AT_REMOVEDIR is rmdir(2), so a + // NON-empty squatter is reported, not cleared. The caller logs that and carries on; nothing + // here should ever start deleting a tree it did not create. + std::fs::create_dir(&squatter).unwrap(); + std::fs::write(squatter.join("planted"), b"x").unwrap(); + assert!( + super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()) + .is_err(), + "a non-empty directory must be reported, not silently left as success" + ); + assert!(squatter.join("planted").exists(), "and not deleted"); + + std::fs::remove_dir_all(&base).ok(); + } + #[test] fn test_write_pid_file_rejects_symlink() { use std::os::unix::fs::symlink; diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 06cee3092..68a005ff7 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -361,6 +361,30 @@ pub fn get_focused_display(displays: Vec) -> Option { } pub fn get_cursor() -> ResultType> { + // DRM/KMS capture: the hardware cursor arrives over the `_drm` stream, not from XFixes. + // + // The MEMOISED `is_x11()` here, deliberately, unlike the capture-path callers that take the + // unmemoised `scrap::is_x11()` because this one latches on first use. The tradeoff is the other + // way round at cursor cadence: the unmemoised form forks `loginctl` per call, and this runs on + // every cursor poll. A latch that guessed wrong costs a cursor served by the wrong source until + // the process restarts, not a capture that cannot start -- and by the time a cursor is being + // polled there is a live session, which is the case the latch reads correctly. + #[cfg(feature = "drm")] + if !is_x11() { + if let Some(id) = crate::server::drm_capturer::drm_cursor_id() { + // In a mixed DRM + PipeWire session the DRM streams only cover the DRM-backed displays; + // when the pointer sits on a PipeWire-served display every DRM stream reports the hidden + // sentinel. Returning that sentinel here would hide the cursor globally, including on the + // PipeWire display where it is still visible, so only report a hidden DRM cursor when it + // is authoritative -- a pure-DRM session. A visible DRM cursor is always authoritative; + // otherwise fall through to the normal cursor path. + if id != scrap::drm_reader::HIDDEN_CURSOR_ID + || !crate::server::display_service::has_non_drm_backed_display() + { + return Ok(Some(id)); + } + } + } let mut res = None; DISPLAY.with(|conn| { if let Ok(d) = conn.try_borrow_mut() { @@ -379,6 +403,32 @@ pub fn get_cursor() -> ResultType> { } pub fn get_cursor_data(hcursor: u64) -> ResultType { + // DRM/KMS capture: return the latest hardware-cursor snapshot from the `_drm` stream. Its id may + // have advanced past `hcursor` between get_cursor() and here, so return the latest rather than + // bailing (which would trigger a MouseCursorService backoff). + // + // Memoised `is_x11()` on purpose, for the reason spelled out in `get_cursor()`; the two must + // agree anyway, since a caller that took the DRM branch there has to take it here. + #[cfg(feature = "drm")] + if !is_x11() { + if let Some(c) = crate::server::drm_capturer::drm_cursor() { + // See get_cursor(): a hidden DRM sentinel is authoritative only in a pure-DRM session. In + // a mixed DRM + PipeWire session fall through so the PipeWire display's cursor is served + // by the normal path instead of being hidden everywhere. + if c.id != scrap::drm_reader::HIDDEN_CURSOR_ID + || !crate::server::display_service::has_non_drm_backed_display() + { + let mut cd: CursorData = Default::default(); + cd.id = c.id; + cd.width = c.width; + cd.height = c.height; + cd.hotx = c.hotx; + cd.hoty = c.hoty; + cd.colors = c.colors.into(); + return Ok(cd); + } + } + } let mut res = None; DISPLAY.with(|conn| { if let Ok(ref mut d) = conn.try_borrow_mut() { @@ -680,6 +730,40 @@ fn start_server(desktop: Option<&Desktop>, server: &mut Option) { } } +/// Whether a just-spawned `--server` is still running after a short grace period, taking ownership of +/// the corpse (clearing `server`) when it is not. `start_server` reports only whether the SPAWN +/// succeeded, which is not the same question: a child that execs and exits immediately still leaves +/// `Some(child)` behind. +/// +/// A child that exits is detected as soon as it does; a healthy one costs the full grace, once per +/// start. A server that dies LATER than this is a different (transient) failure, and the restart +/// throttle in `should_start_server` already bounds that case. +#[cfg(feature = "drm")] +fn server_survived_grace(server: &mut Option) -> bool { + const GRACE: Duration = Duration::from_millis(1000); + const STEP_MS: u64 = 100; + let Some(ps) = server.as_mut() else { + return false; // spawn itself failed + }; + let deadline = Instant::now() + GRACE; + while Instant::now() < deadline { + match ps.try_wait() { + Ok(Some(status)) => { + log::warn!("--server exited {status} within {GRACE:?} of starting"); + *server = None; + return false; + } + Ok(None) => sleep_millis(STEP_MS), + // We cannot tell; treat it as alive rather than tearing down a possibly healthy child. + Err(err) => { + log::error!("error waiting on the just-started --server: {err}"); + return true; + } + } + } + true +} + fn stop_server(server: &mut Option) { if let Some(mut ps) = server.take() { allow_err!(ps.kill()); @@ -810,6 +894,29 @@ pub fn start_os_service() { allow_err!(crate::ipc::start(crate::POSTFIX_SERVICE)); }); + // DRM/KMS capture producer (opt-in `drm` feature): a dedicated thread + runtime that streams + // scanout frames to the user `--server` over the `_drm` service-scoped channel. Runs here + // because this process is the root service that already holds CAP_SYS_ADMIN for the in-process + // (direct-mode) libdrmtap read. + // + // Builder, like every other thread this feature starts: `thread::spawn` PANICS if the thread + // cannot be created (EAGAIN under a thread-count or memory limit), and here that panic would + // unwind out of `start_os_service` -- taking down the root service itself, for a feature whose + // failure should only cost DRM capture. Losing the producer leaves the consumer to fall back to + // PipeWire/X11, which is the same path a host without the feature takes. + #[cfg(feature = "drm")] + if let Err(err) = std::thread::Builder::new() + .name("drm-producer".into()) + .spawn(|| { + crate::ipc::start_drm(); + }) + { + log::warn!( + "failed to spawn the drm capture producer thread: {err}; DRM capture is off for \ + this boot and the consumer falls back to PipeWire/X11" + ); + } + let running = Arc::new(AtomicBool::new(true)); let r = running.clone(); let (mut display, mut xauth): (String, String) = ("".to_owned(), "".to_owned()); @@ -848,7 +955,38 @@ pub fn start_os_service() { ) { stop_subprocess(); force_stop_server(); + // Run the login-screen --server as the active seat0 session user (the greeter + // account) rather than root, so the DRM capture GPU/EGL convert never loads the + // vendor GPU userspace in a privileged process. is_login_wayland() matches a GDM or + // SDDM Wayland greeter (is_gdm_user covers both), and desktop.uid is that greeter's + // uid, so this drops to whichever greeter owns seat0. A greeter is_gdm_user does not + // recognize (e.g. LightDM) never reaches this branch -- it takes the unprivileged + // else-branch below already. A genuine root graphical session (username=="root") + // has no lower uid to drop to, so it stays root. The whole branch is gated on the drm + // feature, so the drm-off build is upstream's single `start_server(None, ..)` line. + #[cfg(not(feature = "drm"))] start_server(None, &mut server); + #[cfg(feature = "drm")] + if desktop.username != "root" && !desktop.uid.is_empty() { + start_server(Some(&desktop), &mut server); + // If dropping to the greeter uid did not produce a RUNNING server, fall back to a + // root --server so the login screen stays remotable instead of looping on a + // failing greeter spawn. This pays the GPU-in-root tradeoff only on that failure + // path, never in the normal greeter case. Liveness, not just spawn success: a + // greeter account that cannot actually run it (a nologin shell, a hardened home, + // no writable config dir) leaves a child that exits at once, and the loop above + // notices only that the child is gone and respawns it, forever, without ever + // reaching this fallback -- so the login screen becomes permanently un-remotable + // on a host where it used to work. + if !server_survived_grace(&mut server) { + log::warn!( + "greeter --server did not stay up; falling back to a root --server" + ); + start_server(None, &mut server); + } + } else { + start_server(None, &mut server); + } } } else if desktop.username != "" { // try kill subprocess "--server" @@ -927,6 +1065,15 @@ pub fn get_active_userid_fresh() -> String { get_values_of_seat0(&[1])[0].clone() } +#[inline] +/// The cached active uid as a number, or `None` when the cache is empty. Unlike `get_active_userid` +/// this NEVER falls back to a blocking `loginctl` seat0 lookup, so it is safe to call on an async +/// runtime thread and on a hot path (e.g. per-frame re-auth): a cache miss returns `None` for the +/// caller to treat as "active session momentarily unknown" rather than stalling on a subprocess. +pub fn get_active_userid_cached() -> Option { + get_active_user_id_name_from_cache().and_then(|(uid, _)| uid.parse::().ok()) +} + fn get_cm() -> bool { // We use `CMD_PS` instead of `ps` to suppress some audit messages on some systems. if let Ok(output) = Command::new(CMD_PS.as_str()).args(vec!["aux"]).output() { @@ -1939,6 +2086,22 @@ mod desktop { self.display = "".to_owned(); self.xauth = "".to_owned(); self.is_rustdesk_subprocess = false; + // Resolve HOME even on this path. Upstream returned without it because nothing then + // consumed a login-Wayland Desktop, but the drm build starts a `--server` as the + // greeter uid here, and a child with no HOME has nowhere to put its config. The + // compositor variables (WAYLAND_DISPLAY, DBUS, DISPLAY, XAUTHORITY) are left blank + // on purpose and are NOT an oversight: the drm capture path talks to the root + // service over `_drm` and to a render node, never to the compositor or the portal, + // which is the entire reason it works at a login screen. `try_start_server_` skips + // empty entries, so the greeter child simply does not get them. + // + // `is_login_wayland` needs `is_gdm_user(username)`, and a current GDM runs its + // greeter as `gdm-greeter`, which that helper does not match -- measured on the + // test host, where the greeter server therefore takes the branch below and gets a + // fully populated environment. This is for the display managers whose greeter user + // does match. + #[cfg(feature = "drm")] + self.get_home(); return; } diff --git a/src/server.rs b/src/server.rs index f02a15a7f..5af982772 100644 --- a/src/server.rs +++ b/src/server.rs @@ -44,6 +44,8 @@ mod clipboard_service; pub use clipboard_service::is_clipboard_service_ok; #[cfg(target_os = "linux")] pub(crate) mod wayland; +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(crate) mod drm_capturer; #[cfg(target_os = "linux")] pub mod uinput; #[cfg(target_os = "linux")] @@ -599,6 +601,25 @@ pub async fn start_server(is_server: bool, no_server: bool) { std::process::exit(-1); } }); + // Warm the DRM availability cache before any client connects, so the first connection does + // not race a cold `_drm` probe and ship an empty display list ("No displays" + retry). + // X11 is skipped -- probing there makes the root service open DRM readers for a path this + // session can never take -- but that decision belongs to `warm_availability`, which already + // makes it, and NOT to this call site. Deciding it here is the same one-shot-at-startup + // mistake the pre-warm had: `is_x11()` answers "x11" whenever loginctl cannot yet name the + // seat0 session, which during a boot is exactly when this runs, and nothing revisits it -- + // so a Wayland host that came up slowly skipped the warm for the life of the process and + // got back the cold-probe "No displays" symptom the warm exists to remove. + #[cfg(all(target_os = "linux", feature = "drm"))] + if let Err(err) = std::thread::Builder::new() + .name("drm-warm".into()) + .spawn(drm_capturer::warm_availability) + { + // Same reason as the root service's startup threads: `thread::spawn` panics on EAGAIN + // and that would abort `start_server`. Skipping the warm costs the first session the + // cold probe, which is what happened before the warm existed. + log::warn!("drm: could not spawn the availability warm ({err}); skipping it"); + } input_service::fix_key_down_timeout_loop(); #[cfg(target_os = "linux")] if input_service::wayland_use_uinput() { diff --git a/src/server/display_service.rs b/src/server/display_service.rs index 8531076a9..3647d7ee6 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -65,6 +65,13 @@ pub(super) fn set_wayland_uinput_rect(rect: (i32, i32, i32, i32)) { WAYLAND_UINPUT_RECT.lock().unwrap().rect = Some(rect); } +// The uinput ABS range currently programmed into the device, for the DRM path's "reapply only when +// it changed" check. The PipeWire path compares it inline in refresh_wayland_uinput_rect_if_changed. +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(super) fn wayland_uinput_rect() -> Option<(i32, i32, i32, i32)> { + WAYLAND_UINPUT_RECT.lock().unwrap().rect +} + #[cfg(target_os = "linux")] pub(super) fn set_wayland_layout_baseline(baseline: Vec) { WAYLAND_LAYOUT_DRIFTED.store(false, Ordering::Relaxed); @@ -328,6 +335,16 @@ fn check_get_displays_changed_msg() -> Option { #[cfg(target_os = "linux")] { if !is_x11() { + // On the DRM/KMS capture path the PipeWire enumeration (which is what feeds + // `SYNC_DISPLAYS` via `check_update_displays`) is bypassed, so populate the sync list + // from the DRM display list here. Without this the display service broadcasts an empty + // list that overwrites the login peer-info displays and the client shows "No displays". + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + if let Some(displays) = super::drm_capturer::get_display_infos() { + SYNC_DISPLAYS.lock().unwrap().check_changed(&displays); + } + } return get_displays_msg(); } } @@ -434,6 +451,33 @@ pub(super) fn get_display_info(idx: usize) -> Option { SYNC_DISPLAYS.lock().unwrap().displays.get(idx).cloned() } +// True when at least one advertised (synced) display is NOT served by the DRM/KMS capture path, +// i.e. a mixed DRM + PipeWire session. The cursor service (platform::linux::get_cursor / +// get_cursor_data) uses this to decide whether a hidden DRM hardware-cursor sentinel is +// authoritative: in a pure-DRM session it is (the pointer is genuinely off every captured CRTC), +// but in a mixed session the sentinel only means the pointer moved onto a PipeWire-served display, +// whose cursor must come from the normal path instead of being hidden everywhere. +// +// When DRM capture is active the advertised list is enumerated from the DRM display list, so a DRM +// list shorter than the synced list means at least one advertised display is served by PipeWire. +#[cfg(all(target_os = "linux", feature = "drm"))] +pub fn has_non_drm_backed_display() -> bool { + match super::drm_capturer::display_count_and_any_demoted() { + // A display served by PipeWire is either ABSENT from the DRM list (a shorter count, e.g. a + // pure-portal display) or PRESENT-BUT-DEMOTED (kept in place at the same index and marked + // offline so the index space stays aligned -- see get_display_infos). The count check alone + // misses the demotion case (same count), so a demoted display is treated as non-DRM-backed + // too. This is what gates the hidden-cursor sentinel: it stays authoritative only in a + // pure-DRM session. The scalar accessor is deliberate: this is polled every cursor tick + // while the sentinel is active, and cloning + geometry-augmenting the whole list per tick + // (what get_display_infos does) answered the same two facts. + Some((count, any_demoted)) => { + count < SYNC_DISPLAYS.lock().unwrap().displays.len() || any_demoted + } + None => false, + } +} + // Display to DisplayInfo // The DisplayInfo is be sent to the peer. pub(super) fn check_update_displays(all: &Vec) { diff --git a/src/server/drm_capturer.rs b/src/server/drm_capturer.rs new file mode 100644 index 000000000..d447715df --- /dev/null +++ b/src/server/drm_capturer.rs @@ -0,0 +1,1670 @@ +// Unprivileged consumer of the root `--service`'s DRM/KMS capture stream: the service does the +// privileged export (open + grab the scanout dma-buf fd), the EGL detile / RGBA convert runs here. + +use crate::ipc::{connect_drm, Data, DrmDisplayInfo}; +use hbb_common::{anyhow::anyhow, bail, log, message_proto::DisplayInfo, tokio, ResultType}; +use scrap::drm_render::RenderConverter; +use scrap::drmtap_dl::drmtap_dmabuf_desc; +use scrap::{Frame, Pixfmt, PixelBuffer, TraitCapturer}; +use std::collections::BTreeMap; +use std::io; +use std::os::fd::{AsRawFd, RawFd}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; + +const HANDSHAKE_TIMEOUT_MS: u64 = 3000; +const DRM_CONNECT_TIMEOUT_MS: u64 = 1000; +/// The service may hold the list back while it wakes sleeping displays: ~3.6s (DRM_WAKE_*). +const DISPLAY_LIST_TIMEOUT_MS: u64 = HANDSHAKE_TIMEOUT_MS + 4000; +/// Covers the connect timeout plus `recv_msg_timeout2` applying DISPLAY_LIST_TIMEOUT_MS TWICE +/// (first byte, then body). The render-node open and the DrmStart send can still overrun it. +const HANDSHAKE_WAIT_MS: u64 = DRM_CONNECT_TIMEOUT_MS + DISPLAY_LIST_TIMEOUT_MS * 2 + 500; +/// Only the header read rechecks `stop`, so bound the body read here rather than relying on + /// `next_raw_into`'s own cap. +const BODY_READ_TIMEOUT: Duration = Duration::from_secs(5); + +struct FrameSlot { + // Row stride is `pixels.len() / height`, possibly padded; the format is per frame. + latest: Option<(usize, usize, Pixfmt, Vec)>, + // TWO slots: two buffers can be idle at once -- the receive path takes one and publishes in two + // SEPARATE acquisitions, so the encoder can hand its borrow back in between. + free: [Option>; 2], + ended: Option, +} + +impl FrameSlot { + fn publish(&mut self, w: usize, h: usize, fmt: Pixfmt, buf: Vec) { + if let Some((.., old)) = self.latest.take() { + self.recycle(old); + } + self.latest = Some((w, h, fmt, buf)); + } + + fn recycle(&mut self, buf: Vec) { + if let Some(slot) = self.free.iter_mut().find(|s| s.is_none()) { + *slot = Some(buf); + } + } + + fn take_free(&mut self) -> Option> { + self.free.iter_mut().find_map(|s| s.take()) + } +} + +struct Shared { + slot: Mutex, + cv: Condvar, +} + +pub struct IpcDrmCapturer { + shared: Arc, + stop: Arc, + display: i32, + connector: Option, + // What the encoder was sized from: CapturerInfo{width,height} is read once, at build time. + session_size: Option<(usize, usize)>, + cur: Vec, + cur_w: usize, + cur_h: usize, + cur_fmt: Pixfmt, + got_frame: bool, +} + +/// A list index is NOT an identity: `drm_enumerate_all_displays` concatenates per-card lists. +fn connector_key(d: &DrmDisplayInfo) -> String { + format!("{}:{}", d.device, d.name) +} + +/// Takes DRM_STATE: never call it while holding one of the per-display maps below. +fn display_info_of(display: i32) -> Option { + match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => list.get(display.max(0) as usize).cloned(), + _ => None, + } +} + +/// A delivered frame resets the streak verdicts (`zero_frame_streak`, `demotes`, `since`) and + /// nothing else. +#[derive(Clone, Copy)] +struct DisplayHealth { + zero_frame_streak: u32, + since: Instant, + demotes: u32, + last_build: Option, + rapid_builds: u32, + /// The dma-buf convert failed for this display. The COMMON cause is multi-GPU: our render node + /// is not the GPU that exported the scanout. Follows the monitor for the process run. + prefer_cpu: bool, +} + +impl DisplayHealth { + fn new() -> Self { + Self { + zero_frame_streak: 0, + since: Instant::now(), + demotes: 0, + last_build: None, + rapid_builds: 0, + prefer_cpu: false, + } + } + + fn demoted(&self) -> bool { + self.zero_frame_streak >= DRM_GRAB_MAX_FAILURES + && self.since.elapsed() < demote_cooldown(self.demotes) + } +} + +static DRM_DISPLAY_HEALTH: Mutex> = Mutex::new(BTreeMap::new()); +const DRM_GRAB_MAX_FAILURES: u32 = 4; +const DEMOTE_COOLDOWN: Duration = Duration::from_secs(30); +const DEMOTE_BACKOFF_MAX_SHIFT: u32 = 4; +const RAPID_REBUILD_WINDOW: Duration = Duration::from_secs(3); +const RAPID_REBUILD_MAX: u32 = 6; + +/// Doubling per demotion up to `DEMOTE_BACKOFF_MAX_SHIFT`; a delivered frame zeroes the demote +/// count (see `frame()`), not decayed by time. +fn demote_cooldown(demotes: u32) -> Duration { + DEMOTE_COOLDOWN * (1u32 << demotes.saturating_sub(1).min(DEMOTE_BACKOFF_MAX_SHIFT)) +} + +#[derive(Debug, PartialEq, Eq)] +enum RefreshOutcome { + Publish, + Unavailable, + Restamp, + /// The evidence is about the PRODUCER, not the hardware: give the verdict up to `Unknown`. + GiveUp, +} + +/// `failures` counts consecutive failures INCLUDING this one, so it is 1 on the first. +fn refresh_outcome(probe: Option, failures: u32) -> RefreshOutcome { + match probe { + Some(0) => RefreshOutcome::Unavailable, + Some(_) => RefreshOutcome::Publish, + None if failures >= DRM_REFRESH_MAX_FAILURES => RefreshOutcome::GiveUp, + None => RefreshOutcome::Restamp, + } +} + +fn drm_prefer_cpu(key: &Option) -> bool { + key.as_ref().is_some_and(|k| { + DRM_DISPLAY_HEALTH + .lock() + .unwrap() + .get(k) + .is_some_and(|h| h.prefer_cpu) + }) +} + +fn drm_set_prefer_cpu(key: &Option) { + if let Some(k) = key { + DRM_DISPLAY_HEALTH + .lock() + .unwrap() + .entry(k.clone()) + .or_insert_with(DisplayHealth::new) + .prefer_cpu = true; + } +} + +fn render_node_count() -> usize { + std::fs::read_dir("/dev/dri").map_or(0, |entries| { + entries + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name() + .to_str() + .and_then(|n| n.strip_prefix("renderD")) + .and_then(|minor| minor.parse::().ok()) + .is_some() + }) + .count() + }) +} + +static UINPUT_REFRESH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +static UINPUT_REFRESH_BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +impl IpcDrmCapturer { + /// The service resolves indices against ITS OWN enumeration, so the receive thread re-resolves + /// `expected` by connector identity and returns the index geometry must be read at. + pub fn new( + display: i32, + expected: Option, + ) -> ResultType<(IpcDrmCapturer, Vec, usize)> { + let shared = Arc::new(Shared { + slot: Mutex::new(FrameSlot { + latest: None, + free: [None, None], + ended: None, + }), + cv: Condvar::new(), + }); + let stop = Arc::new(AtomicBool::new(false)); + let (tx, rx) = std::sync::mpsc::channel::, usize)>>(); + { + let shared = shared.clone(); + let stop = stop.clone(); + std::thread::Builder::new() + .name("drm-recv".into()) + .spawn(move || recv_thread(display, expected, shared, stop, tx)) + .map_err(|err| anyhow!("could not spawn the drm receive thread: {err}"))?; + } + let (displays, wire_idx) = match rx.recv_timeout(Duration::from_millis(HANDSHAKE_WAIT_MS)) { + Ok(res) => res?, + Err(_) => { + // A handshake completing later would stream unowned: Drop never runs here. + stop.store(true, Ordering::SeqCst); + bail!("drm capture handshake timed out"); + } + }; + Ok(( + IpcDrmCapturer { + shared, + stop, + display, + connector: displays.get(wire_idx).map(connector_key), + session_size: displays + .get(wire_idx) + .map(|d| (d.width as usize, d.height as usize)), + cur: Vec::new(), + cur_w: 0, + cur_h: 0, + cur_fmt: Pixfmt::BGRA, + got_frame: false, + }, + displays, + wire_idx, + )) + } + + /// Without an identity, skip rather than record under "", which get_capturer_info reads back + /// as the same key: one unidentifiable display would demote the next. + fn note_session_without_frame(&self) { + let Some(key) = self.connector.clone() else { + log::debug!( + "drm: display {} produced no frame but has no connector identity; \ + not counting it against any display", + self.display + ); + return; + }; + let mut map = DRM_DISPLAY_HEALTH.lock().unwrap(); + let h = map.entry(key).or_insert_with(DisplayHealth::new); + h.zero_frame_streak += 1; + h.since = Instant::now(); + if h.zero_frame_streak == DRM_GRAB_MAX_FAILURES { + h.demotes += 1; + log::warn!( + "drm: display {} produced no frame in {} sessions; using PipeWire for it, \ + retrying DRM in {:?} (demotion {})", + self.display, + h.zero_frame_streak, + demote_cooldown(h.demotes), + h.demotes + ); + } + } +} + +impl Drop for IpcDrmCapturer { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + } +} + +impl TraitCapturer for IpcDrmCapturer { + fn frame<'a>(&'a mut self, timeout: Duration) -> io::Result> { + let deadline = Instant::now() + timeout; + { + let mut slot = self.shared.slot.lock().unwrap(); + loop { + if slot.latest.is_some() || slot.ended.is_some() { + break; + } + let now = Instant::now(); + if now >= deadline { + return Err(io::ErrorKind::WouldBlock.into()); + } + let (guard, _timed_out) = + self.shared.cv.wait_timeout(slot, deadline - now).unwrap(); + slot = guard; + } + if let Some((w, h, fmt, buf)) = slot.latest.take() { + drop(slot); + // convert_to_yuv only refuses a source LARGER than its destination, so a smaller + // frame leaves stale edges on screen. On the FIRST frame nothing changed: the list + // carries the CRTC mode, a frame the scanout fb, different when a CRTC scales. + if self.session_size.is_some_and(|(sw, sh)| (w, h) != (sw, sh)) { + self.shared.slot.lock().unwrap().recycle(buf); + if !self.got_frame { + self.note_session_without_frame(); + } + let (sw, sh) = self.session_size.unwrap_or_default(); + let what = if self.got_frame { + "changed geometry mid-session" + } else { + "never matched its advertised geometry" + }; + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "drm: display {} {what} ({sw}x{sh} -> {w}x{h}); rebuilding", + self.display + ), + )); + } + let previous = std::mem::replace(&mut self.cur, buf); + self.shared.slot.lock().unwrap().recycle(previous); + self.cur_w = w; + self.cur_h = h; + self.cur_fmt = fmt; + if !self.got_frame { + // Clear ONLY the streak: `rapid_builds` is for a display that delivers a first + // frame then fails, and `prefer_cpu` is written on the recv thread. + self.got_frame = true; + if let Some(key) = &self.connector { + if let Some(h) = DRM_DISPLAY_HEALTH.lock().unwrap().get_mut(key) { + h.zero_frame_streak = 0; + h.demotes = 0; + h.since = Instant::now(); + } + } + } + } else { + let err = slot + .ended + .clone() + .unwrap_or_else(|| "drm stream ended".to_owned()); + if !self.got_frame { + self.note_session_without_frame(); + } + return Err(io::Error::new(io::ErrorKind::Other, err)); + } + } + Ok(Frame::PixelBuffer(PixelBuffer::new( + &self.cur, + self.cur_fmt, + self.cur_w, + self.cur_h, + ))) + } +} + +#[tokio::main(flavor = "current_thread")] +async fn recv_thread( + display: i32, + expected: Option, + shared: Arc, + stop: Arc, + tx: std::sync::mpsc::Sender, usize)>>, +) { + let cursor_epoch = next_cursor_epoch(); + let mut conn = match connect_drm(DRM_CONNECT_TIMEOUT_MS).await { + Ok(c) => c, + Err(err) => { + let _ = tx.send(Err(err)); + return; + } + }; + let displays = match conn.recv_msg_timeout2(DISPLAY_LIST_TIMEOUT_MS).await { + Some(Ok((Data::DrmDisplayList(v), _fd))) => v, + Some(Ok((other, _fd))) => { + let _ = tx.send(Err(anyhow!("expected DrmDisplayList, got {:?}", other))); + return; + } + Some(Err(err)) => { + let _ = tx.send(Err(err)); + return; + } + None => { + let _ = tx.send(Err(anyhow!("timed out waiting for DrmDisplayList"))); + return; + } + }; + // Our monitor's index IN THIS CONNECTION'S LIST; `display` indexes the CLIENT's. Measured on a + // T2: a woken 2880x1800 panel re-enters ahead of the Touch Bar, flipping index 0. + let wire_idx = match &expected { + Some(e) => { + match displays + .iter() + .position(|d| d.device == e.device && d.name == e.name) + { + Some(i) => i, + None => { + let _ = tx.send(Err(anyhow!( + "display {display} ({}) is no longer in the service's list; \ + the video service will rebuild against the fresh topology", + e.name + ))); + return; + } + } + } + None => { + let _ = tx.send(Err(anyhow!( + "display {display} is not in the advertised list; not guessing a monitor for it" + ))); + return; + } + }; + // (device, crtc_id) survives a topology change; list indices do not. + let bound_to = displays + .get(wire_idx) + .map(|d| (d.device.clone(), d.crtc_id)); + let our_key = displays.get(wire_idx).map(connector_key); + let render_node = displays + .get(wire_idx) + .or_else(|| displays.first()) + .map(|d| d.render_node.clone()) + .unwrap_or_default(); + // An unnamed exporter on a multi-render-node host fails SILENTLY: on a Jetson + // (scanout nvidia-drm, first render node tegra) the wrong device's import SUCCEEDS and corrupts + // the pixels, so there is no convert error for prefer_cpu to learn from. + let ambiguous_gpu = render_node.is_empty() && render_node_count() > 1; + let force_cpu = drm_prefer_cpu(&our_key) || ambiguous_gpu; + let mut converter = if force_cpu { + None + } else { + RenderConverter::open_render(Some(render_node.as_str())) + }; + let need_cpu = converter.is_none(); + if need_cpu { + log::info!( + "drm: requesting the CPU-converted frame path for display {display} ({})", + if ambiguous_gpu { + "the service did not name the exporting GPU and this host has several render nodes; \ + auto-selecting one can import the scanout on the wrong device and silently corrupt it" + } else if force_cpu { + "a prior consumer convert failed, e.g. multi-GPU render-node mismatch" + } else { + "no render-node convert context: libdrmtap did not load here, or \ + drmtap_open_render found no usable /dev/dri/renderD*" + } + ); + } + if let Err(err) = conn + .send_msg( + &Data::DrmStart { + display: wire_idx as i32, + need_cpu, + }, + None, + ) + .await + { + let _ = tx.send(Err(err)); + return; + } + let _ = tx.send(Ok((displays, wire_idx))); + + let end_reason = loop { + if stop.load(Ordering::SeqCst) { + break "stopped".to_owned(); + } + let (msg, recv_fd) = match conn.recv_msg_timeout2(200).await { + None => continue, // timeout: re-check stop at the loop top + Some(Ok(pair)) => pair, + Some(Err(err)) => break format!("recv: {err}"), + }; + match msg { + Data::DrmFrameDmabuf(desc) => { + let conv = match converter.as_mut() { + Some(c) => c, + None => break "no DRM render node; cannot convert dma-buf frame".to_owned(), + }; + // Valid in THIS process; -1 is an import-once cache hit on `fb_id`. + let received_fd: RawFd = if desc.has_fd { + match recv_fd.as_ref() { + Some(f) => f.as_raw_fd(), + None => { + break "dma-buf frame set has_fd but carried no SCM_RIGHTS fd".to_owned() + } + } + } else { + -1 + }; + let mut ddesc = drmtap_dmabuf_desc { + dma_buf_fd: -1, + width: desc.width, + height: desc.height, + format: desc.format, + modifier: desc.modifier, + fb_id: desc.fb_id, + // RAW: `drm_render::convert` REJECTS an out-of-range count rather than + // clamping, so the count the C reads is the one that was validated. + num_planes: desc.num_planes, + offsets: desc.offsets, + pitches: desc.pitches, + hdr_eotf: desc.hdr_eotf, + hdr_max_nits: desc.hdr_max_nits, + }; + match conv.convert(&mut ddesc, received_fd) { + Ok((data, w, h, fmt)) => { + // Borrowed from the render context, valid only until the next convert. + // Copy into a recycled buffer, and OUTSIDE the slot lock, so a + // multi-megabyte memcpy never holds the encoder off the slot. + let mut buf = shared.slot.lock().unwrap().take_free().unwrap_or_default(); + buf.clear(); + buf.extend_from_slice(data); + let mut slot = shared.slot.lock().unwrap(); + slot.publish(w as usize, h as usize, fmt, buf); + shared.cv.notify_one(); + } + Err(err) if err.kind() == io::ErrorKind::WouldBlock => {} + Err(err) => { + drm_set_prefer_cpu(&our_key); + break format!("convert: {err}"); + } + } + // `recv_fd` closes at the end of this iteration, AFTER convert imported it. + // Ack so the producer RELEASES ONE SEND CREDIT and forwards the next; this bounds + // the socket to a couple of in-flight frames instead of a stale backlog. + if let Err(err) = conn.send_frame_ack().await { + break format!("frame ack: {err}"); + } + } + Data::DrmFrame { width, height } => { + // `frame()` hands this to PixelBuffer::new, which derives the stride as + // `data.len() / height`: height==0 would DIVIDE BY ZERO. + if width == 0 || height == 0 { + break format!("cpu frame: degenerate geometry {width}x{height}"); + } + let need = (width as usize) + .saturating_mul(height as usize) + .saturating_mul(4); + let mut buf = shared.slot.lock().unwrap().take_free().unwrap_or_default(); + match tokio::time::timeout(BODY_READ_TIMEOUT, conn.next_raw_into(&mut buf)).await { + Err(_) => break "cpu frame body read timed out".to_owned(), + Ok(Ok(())) => { + if buf.len() < need { + break format!( + "cpu frame: body {} bytes < {need} for {width}x{height}", + buf.len() + ); + } + let mut slot = shared.slot.lock().unwrap(); + slot.publish(width as usize, height as usize, Pixfmt::BGRA, buf); + shared.cv.notify_one(); + } + Ok(Err(err)) => break format!("frame body: {err}"), + } + // Ack this CPU frame too (flow control; see the dma-buf arm above). + if let Err(err) = conn.send_frame_ack().await { + break format!("frame ack: {err}"); + } + } + Data::DrmCursor { + id, + width, + height, + hotx, + hoty, + } => { + // get_cursor_data() hands `colors` straight to the client, which renders + // width*height*4 RGBA bytes: a short body would make it READ PAST THE BUFFER. A + // hidden-cursor sentinel arrives as 1x1 with a 4-byte body, so `need` is 4 and the + // check is live. + let need = (width as usize) + .saturating_mul(height as usize) + .saturating_mul(4); + let mut raw = Vec::new(); + match tokio::time::timeout(BODY_READ_TIMEOUT, conn.next_raw_into(&mut raw)).await { + Err(_) => break "cursor body read timed out".to_owned(), + Ok(Ok(())) => { + if raw.len() < need { + break format!( + "cursor body {} bytes < {need} for {width}x{height}", + raw.len() + ); + } + set_drm_cursor( + display, + cursor_epoch, + DrmCursorData { + id, + width: width as i32, + height: height as i32, + hotx, + hoty, + colors: raw, + }, + ); + } + Ok(Err(err)) => break format!("cursor body: {err}"), + } + } + Data::DrmDisplaysChanged(list) => { + // `display` (the CLIENT's index) and NOT `wire_idx`, deliberately. `bound_to` is an + // identity `(device, crtc_id)`, not a position, so this asks "does that slot still + // name MY monitor"; and the swap below installs this list as DRM_STATE, which is the + // client-space list display_service re-advertises and input is mapped through. + // Probing `wire_idx` stays quiet in exactly the case this guard exists for: a stream + // whose wire_idx differs from display keeps running while the client's index comes to + // mean another monitor. Checked BEFORE the swap, against the topology this stream + // started on. + let now_at_our_index = list + .get(display.max(0) as usize) + .map(|d| (d.device.clone(), d.crtc_id)); + if bound_to.is_some() && now_at_our_index != bound_to { + swap_available_displays(list); + scrap::wayland::display::clear_wayland_displays_cache(); + break match (&bound_to, &now_at_our_index) { + (Some((_, was)), Some((_, now))) => format!( + "hotplug renumbered display {display}: it was crtc {was}, now crtc {now}" + ), + _ => format!("hotplug removed display {display} from the list"), + }; + } + swap_available_displays(list); + scrap::wayland::display::clear_wayland_displays_cache(); + UINPUT_REFRESH_GEN.fetch_add(1, Ordering::AcqRel); + if !UINPUT_REFRESH_BUSY.swap(true, Ordering::AcqRel) { + // Taken BEFORE the spawn and moved in: `Builder::spawn` can FAIL with EAGAIN after + // the swap, so a guard built inside the closure would never exist and the flag + // would stay set for the PROCESS LIFETIME. + let mut busy = UinputRefreshGuard(true); + let spawned = std::thread::Builder::new() + .name("drm-uinput-refresh".into()) + .spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(err) => { + log::warn!( + "drm: uinput refresh worker could not build a runtime: {err}" + ); + return; // the guard hands the slot back + } + }; + let mut served = 0u64; + loop { + let g = UINPUT_REFRESH_GEN.load(Ordering::Acquire); + if g != served { + served = g; + rt.block_on(super::wayland::update_uinput_resolution()); + continue; + } + busy.release(); + if UINPUT_REFRESH_GEN.load(Ordering::Acquire) == served { + break; + } + if !busy.retake() { + break; // another handler already started a fresh worker + } + } + }); + if let Err(err) = spawned { + log::error!("drm: could not spawn the uinput refresh worker: {err}"); + } + } + } + _ => {} // ignore any unexpected control message + } + }; + log::info!("drm capture stream ended: {end_reason}"); + // Drop the render context on THIS thread: its EGL state + cached imports are thread-local and + // a cross-thread close strands them. Never in `Drop`, which runs on the encoder thread. + drop(converter); + remove_drm_cursor(display, cursor_epoch); + let mut slot = shared.slot.lock().unwrap(); + slot.ended = Some(format!("drm stream ended ({end_reason})")); + shared.cv.notify_one(); +} + +// Keyed by display index: the cursor lives on whichever CRTC the pointer is over and every other +// stream reports a hidden sentinel, which under a single global would clobber it. +#[derive(Clone)] +pub struct DrmCursorData { + pub id: u64, + pub width: i32, + pub height: i32, + pub hotx: i32, + pub hoty: i32, + pub colors: Vec, +} + +static DRM_CURSOR: Mutex> = Mutex::new(BTreeMap::new()); +// Monotonic per-stream tag: a rebuilt stream reuses the display index, so a torn-down stream drops +// its entry ONLY if the epoch still matches. +static DRM_CURSOR_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +fn next_cursor_epoch() -> u64 { + DRM_CURSOR_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +// Compare-and-set: a still-draining predecessor stream (older epoch) must not overwrite the entry a +// replacement stream (newer epoch) already published. Only accept a write whose epoch is at least +// the stored one. +fn set_drm_cursor(display: i32, epoch: u64, c: DrmCursorData) { + let mut map = DRM_CURSOR.lock().unwrap(); + match map.get(&display) { + Some((stored, _)) if *stored > epoch => {} + _ => { + map.insert(display, (epoch, c)); + } + } +} + +fn remove_drm_cursor(display: i32, epoch: u64) { + let mut map = DRM_CURSOR.lock().unwrap(); + if map.get(&display).map(|(e, _)| *e) == Some(epoch) { + map.remove(&display); + } +} + +fn with_drm_cursor(f: impl Fn(&DrmCursorData) -> T) -> Option { + let map = DRM_CURSOR.lock().unwrap(); + map.values() + .map(|(_, c)| c) + .find(|c| c.id != scrap::drm_reader::HIDDEN_CURSOR_ID) + .or_else(|| map.values().map(|(_, c)| c).next()) + .map(f) +} + +pub fn drm_cursor_id() -> Option { + with_drm_cursor(|c| c.id) +} + +/// Snapshot of the DRM hardware cursor, or None. The pixels are premultiplied ARGB and are passed +/// through as-is, like the XFixes path, so the client sees one cursor format from either backend. +pub fn drm_cursor() -> Option { + with_drm_cursor(|c| c.clone()) +} + +enum ProbeState { + Unknown, + Unavailable(Instant), + Available(Instant, Vec), +} + +static DRM_STATE: Mutex = Mutex::new(ProbeState::Unknown); +const NEGATIVE_TTL: Duration = Duration::from_secs(30); +const POSITIVE_TTL: Duration = Duration::from_secs(15); + +/// Runs on a throwaway thread: a nested `#[tokio::main]` panics if called from inside a runtime. +fn query_displays() -> ResultType> { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("drm-query".into()) + .spawn(move || { + let _ = tx.send(query_displays_async()); + }) + .map_err(|err| anyhow!("could not spawn the drm display query thread: {err}"))?; + rx.recv_timeout(Duration::from_millis(HANDSHAKE_WAIT_MS)) + .map_err(|_| anyhow!("drm display query timed out"))? +} + +#[tokio::main(flavor = "current_thread")] +async fn query_displays_async() -> ResultType> { + query_displays_inner().await +} + +async fn query_displays_inner() -> ResultType> { + let mut conn = connect_drm(DRM_CONNECT_TIMEOUT_MS).await?; + match conn.recv_msg_timeout2(DISPLAY_LIST_TIMEOUT_MS).await { + Some(Ok((Data::DrmDisplayList(v), _fd))) => Ok(v), + Some(Ok((other, _fd))) => Err(anyhow!("expected DrmDisplayList, got {:?}", other)), + Some(Err(err)) => Err(err), + None => Err(anyhow!("timed out waiting for DrmDisplayList")), + } +} + +static DRM_PROBE_FAILURES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); +const DRM_PROBE_MAX_FAILURES: u32 = 5; +static DRM_REFRESH_FAILURES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); +const DRM_REFRESH_MAX_FAILURES: u32 = 3; +// Single-flight, so is_available() never calls query_displays() (~4s of IPC) holding DRM_STATE. +static DRM_PROBE_IN_FLIGHT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Advanced by every publish, so a slow UNLOCKED probe can tell a newer verdict landed meanwhile. +static DRM_STATE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// EVERY verdict change to DRM_STATE goes through here so the generation stays truthful; the TTL + /// restamp in `refresh_available_async` is the one direct write. +#[inline] +fn publish_probe_state(st: &mut ProbeState, next: ProbeState) { + *st = next; + DRM_STATE_GEN.fetch_add(1, Ordering::Release); +} + +/// Releases DRM_PROBE_IN_FLIGHT on EVERY exit; a leaked release wedges all future probes. +struct ProbeInFlightGuard; +impl Drop for ProbeInFlightGuard { + fn drop(&mut self) { + DRM_PROBE_IN_FLIGHT.store(false, Ordering::Release); + } +} + +/// Ownership of `UINPUT_REFRESH_BUSY`, released on every exit. It is handed back and re-taken +/// mid-loop, so releasing on drop unconditionally would clear a flag a REPLACEMENT worker owns. +struct UinputRefreshGuard(bool); +impl UinputRefreshGuard { + fn release(&mut self) { + if self.0 { + self.0 = false; + UINPUT_REFRESH_BUSY.store(false, Ordering::Release); + } + } + fn retake(&mut self) -> bool { + self.0 = !UINPUT_REFRESH_BUSY.swap(true, Ordering::AcqRel); + self.0 + } +} +impl Drop for UinputRefreshGuard { + fn drop(&mut self) { + self.release(); + } +} + +/// Never probes, never blocks: the form the ROUTING gates must use. Seconds of IPC inside +/// `wayland::clear()`, `is_inited()` or the display enumeration trips "deadline has elapsed". +pub(super) fn is_available_cached() -> bool { + matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)) +} + +/// MAY BLOCK for seconds: never a routing gate. +pub(super) fn is_available() -> bool { + let verdict = { + let mut st = DRM_STATE.lock().unwrap(); + if let ProbeState::Unavailable(since) = &*st { + if since.elapsed() >= NEGATIVE_TTL { + publish_probe_state(&mut st, ProbeState::Unknown); + DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); + } + } + match &*st { + ProbeState::Available(since, _) => Some((true, since.elapsed() >= POSITIVE_TTL)), + ProbeState::Unavailable(_) => Some((false, false)), + ProbeState::Unknown => None, // fall through and probe with the lock released + } + }; + if let Some((available, stale)) = verdict { + if stale { + refresh_available_async(); + } + return available; + } + if DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)); + } + let _in_flight = ProbeInFlightGuard; + let t = Instant::now(); + let result = query_displays(); + let mut st = DRM_STATE.lock().unwrap(); + let available = match result { + Ok(list) if !list.is_empty() => { + log::debug!( + "drm: availability probe -> available ({} displays) in {:?}", + list.len(), + t.elapsed() + ); + DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); + true + } + Ok(_) => { + log::info!("drm: availability probe -> no displays in {:?}", t.elapsed()); + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + false + } + Err(err) => { + let n = DRM_PROBE_FAILURES.fetch_add(1, Ordering::Relaxed) + 1; + if n >= DRM_PROBE_MAX_FAILURES { + log::info!("drm: availability probe failed {n}x ({err}); disabling DRM"); + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + } else { + log::info!( + "drm: availability probe failed ({err}), attempt {n}/{DRM_PROBE_MAX_FAILURES}; will retry" + ); + } + false + } + }; + drop(st); + available +} + +fn refresh_available_async() { + if DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return; + } + let in_flight = ProbeInFlightGuard; + let sampled_gen = { + let st = DRM_STATE.lock().unwrap(); + if !matches!(&*st, ProbeState::Available(..)) { + return; + } + DRM_STATE_GEN.load(Ordering::Acquire) + }; + let spawned = std::thread::Builder::new() + .name("drm-avail-refresh".into()) + .spawn(move || { + let _in_flight = in_flight; + let result = query_displays(); + let mut st = DRM_STATE.lock().unwrap(); + if DRM_STATE_GEN.load(Ordering::Acquire) != sampled_gen { + return; + } + let failures = match &result { + Ok(_) => { + DRM_REFRESH_FAILURES.store(0, Ordering::Relaxed); + 0 + } + Err(_) => DRM_REFRESH_FAILURES.fetch_add(1, Ordering::Relaxed) + 1, + }; + match refresh_outcome(result.as_ref().ok().map(|l| l.len()), failures) { + RefreshOutcome::Publish => { + let fresh = result.unwrap_or_default(); + let changed = match &*st { + ProbeState::Available(_, old) => *old != fresh, + _ => true, + }; + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), fresh)); + if changed { + drop(st); + scrap::wayland::display::clear_wayland_displays_cache(); + } + } + RefreshOutcome::Unavailable => { + log::info!("drm: refresh -> 0 displays, marking DRM unavailable"); + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + } + // Only the TTL stamp moves, so this does NOT go through publish_probe_state. + RefreshOutcome::Restamp => { + if let ProbeState::Available(since, _) = &mut *st { + *since = Instant::now(); + } + } + RefreshOutcome::GiveUp => { + log::info!( + "drm: availability refresh failed {failures}x ({:?}); the producer looks \ + gone, dropping the cached verdict so the next enumeration re-probes", + result.as_ref().err() + ); + DRM_REFRESH_FAILURES.store(0, Ordering::Relaxed); + publish_probe_state(&mut st, ProbeState::Unknown); + } + } + }); + // Nothing to release: the guard moved into the closure and drops with it. Clearing the flag + // explicitly would let TWO PROBES RUN AT ONCE, since another refresh may already hold it. + if let Err(err) = spawned { + log::warn!( + "drm: could not spawn the availability refresh thread: {err}; the cached verdict \ + stays stale until the next probe" + ); + } +} + +pub(super) fn warm_availability() { + // The gate is INSIDE the loop because `get_display_server()` answers "x11" whenever loginctl + // cannot yet name the seat0 session. `scrap::is_x11()` is the UNMEMOISED form. + for _ in 0..10 { + if scrap::is_x11() { + std::thread::sleep(Duration::from_millis(300)); + continue; + } + if matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)) { + return; + } + match query_displays() { + Ok(list) if !list.is_empty() => { + log::info!("drm: consumer cache warmed ({} displays) at startup", list.len()); + publish_probe_state(&mut DRM_STATE.lock().unwrap(), ProbeState::Available(Instant::now(), list)); + return; + } + _ => std::thread::sleep(Duration::from_millis(300)), + } + } + log::info!("drm: consumer cache warm found no producer at startup (will probe lazily)"); +} + +/// The service holds its answer until the topology settles. Replaces only an `Available` verdict. +pub(super) async fn refresh_displays_for_login() { + let sampled_gen = { + let st = DRM_STATE.lock().unwrap(); + if !matches!(&*st, ProbeState::Available(..)) { + return; + } + DRM_STATE_GEN.load(Ordering::Acquire) + }; + let t = Instant::now(); + match query_displays_inner().await { + Ok(list) if !list.is_empty() => { + let changed = { + let mut st = DRM_STATE.lock().unwrap(); + if DRM_STATE_GEN.load(Ordering::Acquire) != sampled_gen { + log::debug!( + "drm: login display refresh superseded while probing; keeping the newer list" + ); + return; + } + match &*st { + ProbeState::Available(_, old) => { + let changed = *old != list; + log::debug!( + "drm: login display refresh -> {} display(s) in {:?}{}", + list.len(), + t.elapsed(), + if changed { " (list changed)" } else { "" } + ); + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); + changed + } + _ => return, + } + }; + if changed { + scrap::wayland::display::clear_wayland_displays_cache(); + } + } + Ok(_) => log::debug!( + "drm: login display refresh found no displays in {:?}; keeping the cached list", + t.elapsed() + ), + Err(err) => log::debug!( + "drm: login display refresh failed in {:?} ({err}); keeping the cached list", + t.elapsed() + ), + } +} + +/// Mirrors get_display_infos: only a MULTI-display host advertises a demoted display. +pub(super) fn display_count_and_any_demoted() -> Option<(usize, bool)> { + // Snapshot the identity keys under DRM_STATE, then consult health with DRM_STATE RELEASED -- + // same order as get_display_infos: never hold DRM_STATE while taking a per-display map. + let (len, keys): (usize, Vec) = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => ( + list.len(), + if list.len() > 1 { + list.iter().map(connector_key).collect() + } else { + Vec::new() + }, + ), + _ => return None, + }; + let any_demoted = if len > 1 { + let health = DRM_DISPLAY_HEALTH.lock().unwrap(); + keys.iter() + .any(|k| health.get(k).is_some_and(|h| h.demoted())) + } else { + false + }; + Some((len, any_demoted)) +} + +/// Releases DRM_STATE before taking the health map: never hold it while taking a per-display map. +pub(super) fn get_display_infos() -> Option> { + let list = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => list.clone(), + _ => return None, + }; + let multi = list.len() > 1; + let mut infos = augment_with_wayland_geometry(&list); + // The portal exposes one whole-desktop stream, so a demoted display on a multi-monitor host + // has nothing geometry-consistent to fall back to: OFFLINE but KEEPING its list position, so + // the index space stays aligned with get_capturer_info(). A single-display host stays online. + if multi { + let health = DRM_DISPLAY_HEALTH.lock().unwrap(); + for (idx, info) in infos.iter_mut().enumerate() { + let key = match list.get(idx) { + Some(d) => connector_key(d), + None => continue, + }; + if health.get(&key).is_some_and(|h| h.demoted()) { + info.online = false; + } + } + } + Some(infos) +} + +/// Index of the compositor's PRIMARY output; 0 when unknown. Asking `assign_wayland_outputs` makes +/// the advertised primary and geometry agree, but not below two connectors or two outputs, where +/// `augment_with_wayland_geometry` declines to run the assignment. +pub(super) fn get_primary_index() -> usize { + let list = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => list.clone(), + _ => return 0, + }; + let wl = scrap::wayland::display::get_displays(); + if wl.displays.is_empty() { + return 0; + } + assign_wayland_outputs(&list, &wl.displays) + .iter() + .position(|assigned| *assigned == Some(wl.primary)) + .unwrap_or(0) +} + +/// DRM reports every monitor at physical size and origin (0,0), stacking a multi-monitor client. +fn augment_with_wayland_geometry(drm: &[DrmDisplayInfo]) -> Vec { + let wl = scrap::wayland::display::get_displays(); + let mut infos: Vec = drm.iter().map(display_info_from_drm).collect(); + if drm.len() < 2 || wl.displays.len() < 2 { + return infos; + } + let matched = assign_wayland_outputs(drm, &wl.displays); + for (i, info) in infos.iter_mut().enumerate() { + let Some(w) = matched[i].map(|j| &wl.displays[j]) else { + continue; + }; + info.x = w.x; + info.y = w.y; + if let Some((lw, lh)) = w.logical_size { + if lw > 0 && lh > 0 { + info.scale = drm[i].width as f64 / lw as f64; + info.original_resolution = super::display_service::get_original_resolution( + &drm[i].name, + lw as usize, + lh as usize, + ); + } + } + } + infos +} + +/// Each output goes to at most one connector; unmatched ones take the next free output of the same +/// size, else the next free one in layout order, since leaving them unaugmented keeps them all at +/// DRM's (0,0). +fn assign_wayland_outputs( + drm: &[DrmDisplayInfo], + wl: &[hbb_common::platform::linux::WaylandDisplayInfo], +) -> Vec> { + let mut taken = vec![false; wl.len()]; + let mut matched: Vec> = vec![None; drm.len()]; + for (i, d) in drm.iter().enumerate() { + if let Some(j) = match_wayland_display(d, wl, &taken) { + matched[i] = Some(j); + taken[j] = true; + } + } + for (i, d) in drm.iter().enumerate() { + if matched[i].is_some() { + continue; + } + let free_same_size = wl + .iter() + .enumerate() + .position(|(j, w)| !taken[j] && w.width == d.width as i32 && w.height == d.height as i32); + let Some(j) = free_same_size.or_else(|| taken.iter().position(|t| !t)) else { + continue; // more connectors than outputs; leave the rest unaugmented + }; + log::warn!( + "drm: connector {} matched no compositor output by name or by a unique resolution; \ + falling back to layout order and taking {} at ({}, {})", + d.name, + wl[j].name, + wl[j].x, + wl[j].y + ); + matched[i] = Some(j); + taken[j] = true; + } + matched +} + +fn match_wayland_display( + d: &DrmDisplayInfo, + wl: &[hbb_common::platform::linux::WaylandDisplayInfo], + taken: &[bool], +) -> Option { + let dn = normalize_connector(&d.name); + if let Some((j, _)) = wl + .iter() + .enumerate() + .find(|(j, w)| !taken[*j] && normalize_connector(&w.name) == dn) + { + return Some(j); + } + let same_res: Vec = wl + .iter() + .enumerate() + .filter(|(j, w)| !taken[*j] && w.width == d.width as i32 && w.height == d.height as i32) + .map(|(j, _)| j) + .collect(); + if same_res.len() == 1 { + return Some(same_res[0]); + } + None +} + +/// DRM inserts a single-letter type discriminator the compositor drops ("HDMI-A-1" -> "HDMI-1"). +/// Only a *letter* folds: a single *digit* is an MST port index, so "DP-1-2" is not "DP-2". +fn normalize_connector(name: &str) -> String { + let parts: Vec<&str> = name.split('-').collect(); + if parts.len() == 3 && parts[1].len() == 1 && parts[1].chars().all(|c| c.is_ascii_alphabetic()) { + format!("{}-{}", parts[0], parts[2]) + } else { + name.to_string() + } +} + +fn swap_available_displays(list: Vec) { + let mut st = DRM_STATE.lock().unwrap(); + if matches!(&*st, ProbeState::Available(..)) { + if list.is_empty() { + log::info!("drm: hotplug refresh -> 0 displays, marking DRM unavailable"); + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + } else { + log::info!("drm: hotplug refresh -> {} display(s)", list.len()); + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); + } + } +} + +fn display_info_from_drm(d: &DrmDisplayInfo) -> DisplayInfo { + let original_resolution = + super::display_service::get_original_resolution(&d.name, d.width as usize, d.height as usize); + DisplayInfo { + x: d.x, + y: d.y, + width: d.width as i32, + height: d.height as i32, + name: d.name.clone(), + online: d.active, + cursor_embedded: false, + original_resolution, + scale: 1.0, + ..Default::default() + } +} + +/// Deliberately does NOT publish the handshake list into DRM_STATE: it is read before a possibly +/// seconds-long stall, and when `wire_idx != display_idx` it is ordered differently. +pub(super) fn get_capturer_info( + display_idx: usize, +) -> ResultType { + let expected = display_info_of(display_idx as i32); + let key = expected.as_ref().map(connector_key); + { + let mut map = DRM_DISPLAY_HEALTH.lock().unwrap(); + if let Some(h) = key.as_ref().and_then(|k| map.get_mut(k)) { + if h.zero_frame_streak >= DRM_GRAB_MAX_FAILURES { + if h.demoted() { + bail!( + "drm capture for display {display_idx} repeatedly produced no frame; using PipeWire" + ); + } + h.zero_frame_streak = 0; + h.since = Instant::now(); + } + } + } + // Built FIRST: a transient `_drm` outage must NOT count toward the flap threshold below. + let (capturer, displays, wire_idx) = IpcDrmCapturer::new(display_idx as i32, expected)?; + // The initial build counts 0, so demotion fires on the (RAPID_REBUILD_MAX + 1)-th in a window. + if let Some(key) = key.clone() { + let now = Instant::now(); + let mut map = DRM_DISPLAY_HEALTH.lock().unwrap(); + let h = map.entry(key).or_insert_with(DisplayHealth::new); + h.rapid_builds = match h.last_build { + Some(last) if now.duration_since(last) < RAPID_REBUILD_WINDOW => h.rapid_builds + 1, + _ => 0, + }; + h.last_build = Some(now); + if h.rapid_builds >= RAPID_REBUILD_MAX { + log::warn!( + "drm: display {display_idx} rebuilt {} times within {RAPID_REBUILD_WINDOW:?}; flapping, falling back to PipeWire", + h.rapid_builds + ); + h.zero_frame_streak = DRM_GRAB_MAX_FAILURES; + h.since = now; + h.demotes += 1; + bail!("drm capture for display {display_idx} is flapping; using PipeWire"); + } + } + let ndisplay = displays.len(); + // From the entry the stream was BOUND to; `display_idx` is a position in the CLIENT's list. + let d = displays + .get(wire_idx) + .ok_or_else(|| anyhow!("drm display index {wire_idx} out of range ({ndisplay})"))? + .clone(); + // Publish the compositor's LOGICAL origin (what get_display_infos advertises) so the origin + // matches the reported geometry; KEEP the raw PHYSICAL dimensions for the capture buffer. + let origin = augment_with_wayland_geometry(&displays) + .get(wire_idx) + .map(|di| (di.x, di.y)) + .unwrap_or((d.x, d.y)); + Ok(super::video_service::CapturerInfo { + origin, + width: d.width as usize, + height: d.height as usize, + ndisplay, + current: display_idx, + privacy_mode_id: 0, + _capturer_privacy_mode_id: 0, + capturer: Box::new(capturer), + }) +} + +#[cfg(test)] +mod drm_capturer_tests { + use super::*; + + fn capturer_with(session: Option<(usize, usize)>) -> IpcDrmCapturer { + capturer_named(session, None) + } + + // DRM_DISPLAY_HEALTH is process-wide and tests run in parallel: pass each test its OWN key. + fn capturer_named(session: Option<(usize, usize)>, key: Option<&str>) -> IpcDrmCapturer { + let connector = key.map(|k| k.to_owned()); + IpcDrmCapturer { + shared: Arc::new(Shared { + slot: Mutex::new(FrameSlot { + latest: None, + free: [None, None], + ended: None, + }), + cv: Condvar::new(), + }), + stop: Arc::new(AtomicBool::new(false)), + display: 0, + connector, + session_size: session, + cur: Vec::new(), + cur_w: 0, + cur_h: 0, + cur_fmt: Pixfmt::BGRA, + got_frame: false, + } + } + + fn zero_frame_streak_of(c: &IpcDrmCapturer) -> u32 { + let key = c.connector.clone().expect("this check needs an identity"); + DRM_DISPLAY_HEALTH + .lock() + .unwrap() + .get(&key) + .map(|h| h.zero_frame_streak) + .unwrap_or(0) + } + + fn put_frame(c: &IpcDrmCapturer, w: usize, h: usize) { + let mut buf = c.shared.slot.lock().unwrap().take_free().unwrap_or_default(); + buf.clear(); + buf.resize(w * h * 4, 0); + let mut slot = c.shared.slot.lock().unwrap(); + slot.publish(w, h, Pixfmt::BGRA, buf); + } + + #[test] + fn a_delivered_frame_clears_the_streak_but_keeps_the_cadence_and_the_convert_verdict() { + let key = "test:frame-keeps-cadence"; + let mut c = capturer_named(Some((64, 32)), Some(key)); + { + let mut map = DRM_DISPLAY_HEALTH.lock().unwrap(); + let h = map.entry(key.to_owned()).or_insert_with(DisplayHealth::new); + h.zero_frame_streak = 2; + h.demotes = 1; + h.rapid_builds = 3; + h.last_build = Some(Instant::now()); + h.prefer_cpu = true; + } + put_frame(&c, 64, 32); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + + // Copy out and RELEASE the guard before asserting: a failing assertion while holding + // process-wide DRM_DISPLAY_HEALTH poisons the mutex for every sibling test. + let h = { + let map = DRM_DISPLAY_HEALTH.lock().unwrap(); + *map.get(key).expect("the entry must SURVIVE a delivered frame") + }; + assert_eq!(h.zero_frame_streak, 0, "a delivered frame refutes the zero-frame streak"); + assert_eq!(h.demotes, 0, "and the demotion count that streak drove"); + assert_eq!( + h.rapid_builds, 3, + "but it says NOTHING about the rebuild cadence: keeping it is what lets the flap guard \ + reach RAPID_REBUILD_MAX for a display that delivers a first frame and then fails" + ); + assert!(h.last_build.is_some(), "same for the timestamp the cadence is measured from"); + assert!( + h.prefer_cpu, + "and nothing about which GPU exports the scanout: only a topology change may clear it" + ); + } + + #[test] + fn frame_of_the_session_size_is_delivered() { + let mut c = capturer_with(Some((64, 32))); + put_frame(&c, 64, 32); + assert!( + matches!(c.frame(Duration::from_millis(50)), Ok(_)), + "a frame matching the session geometry must be delivered" + ); + assert!(c.got_frame); + } + + #[test] + fn a_smaller_frame_ends_the_session_instead_of_being_encoded() { + let mut c = capturer_named(Some((1920, 1080)), Some("test:mid-session-shrink")); + put_frame(&c, 1920, 1080); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + put_frame(&c, 1280, 720); + let err = match c.frame(Duration::from_millis(50)) { + Err(e) => e, + Ok(_) => panic!("a mid-session shrink must be a hard error, not a delivered frame"), + }; + assert!(err.to_string().contains("changed geometry mid-session")); + assert!( + c.got_frame, + "the rebuild must not look like a display that never produced a frame" + ); + assert_eq!( + zero_frame_streak_of(&c), + 0, + "a session that streamed must not be counted as one that produced nothing" + ); + } + + #[test] + fn a_first_frame_that_never_matched_counts_as_a_session_without_frames() { + let mut c = capturer_named(Some((1920, 1080)), Some("test:never-matched")); + put_frame(&c, 1280, 720); + let err = match c.frame(Duration::from_millis(50)) { + Err(e) => e, + Ok(_) => panic!("a first frame off the advertised geometry must be a hard error"), + }; + assert!(err.to_string().contains("never matched its advertised geometry")); + assert!(!c.got_frame, "no frame reached the encoder, so none was produced"); + assert_eq!( + zero_frame_streak_of(&c), + 1, + "the display must be on its way to a PipeWire demotion, not just rebuilding" + ); + } + + #[test] + fn a_larger_frame_ends_the_session_too() { + let mut c = capturer_with(Some((1280, 720))); + put_frame(&c, 1920, 1080); + assert!(matches!(c.frame(Duration::from_millis(50)), Err(_))); + } + + #[test] + fn unknown_session_size_delivers_whatever_arrives() { + let mut c = capturer_with(None); + put_frame(&c, 800, 600); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + } + + fn drm_display(name: &str, w: u32, h: u32) -> DrmDisplayInfo { + DrmDisplayInfo { + name: name.to_owned(), + crtc_id: 1, + x: 0, + y: 0, + width: w, + height: h, + active: true, + render_node: String::new(), + device: String::new(), + } + } + + fn wl_display( + name: &str, + x: i32, + y: i32, + w: i32, + h: i32, + ) -> hbb_common::platform::linux::WaylandDisplayInfo { + hbb_common::platform::linux::WaylandDisplayInfo { + name: name.to_owned(), + x, + y, + width: w, + height: h, + logical_size: Some((w, h)), + refresh_rate: 60, + } + } + + #[test] + fn frame_buffers_circulate_instead_of_being_reallocated() { + let mut c = capturer_with(Some((64, 32))); + put_frame(&c, 64, 32); + put_frame(&c, 64, 32); + let recycled = c + .shared + .slot + .lock() + .unwrap() + .free + .iter() + .find_map(|b| b.as_ref()) + .map(|b| b.as_ptr()); + assert!( + recycled.is_some(), + "a superseded frame must be handed back, not dropped" + ); + put_frame(&c, 64, 32); + assert_eq!( + c.shared + .slot + .lock() + .unwrap() + .latest + .as_ref() + .map(|(.., b)| b.as_ptr()), + recycled, + "the receive path must refill the recycled buffer rather than allocate" + ); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + assert!( + c.shared.slot.lock().unwrap().free.iter().any(|b| b.is_some()), + "the buffer the encoder finished with must be handed back to the receive path" + ); + } + + // Against a single free slot this asserts red: counting the offers is the point. + #[test] + fn two_idle_buffers_are_both_kept_rather_than_one_being_dropped() { + let mut c = capturer_with(Some((64, 32))); + put_frame(&c, 64, 32); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + while c.shared.slot.lock().unwrap().take_free().is_some() {} + + put_frame(&c, 64, 32); // fills a fresh buffer (nothing on offer) and publishes it + put_frame(&c, 64, 32); // supersedes it -> deposit #1 + assert_eq!( + c.shared.slot.lock().unwrap().free.iter().flatten().count(), + 1, + "the superseded frame is the first idle buffer" + ); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + assert_eq!( + c.shared.slot.lock().unwrap().free.iter().flatten().count(), + 2, + "both idle buffers must be kept; a single slot dropped the older one" + ); + } + + #[test] + fn outputs_are_matched_by_name_across_the_drm_naming_difference() { + let drm = [drm_display("HDMI-A-1", 1920, 1080), drm_display("DP-1", 2560, 1440)]; + let wl = [wl_display("DP-1", 1920, 0, 2560, 1440), wl_display("HDMI-1", 0, 0, 1920, 1080)]; + assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(1), Some(0)]); + } + + // The M10 case: same model and resolution, names that do not normalize to the compositor's. + #[test] + fn identical_monitors_that_match_no_name_take_layout_order() { + let drm = [drm_display("DP-1", 1920, 1080), drm_display("DP-2", 1920, 1080)]; + let wl = [ + wl_display("Unknown-1", 0, 0, 1920, 1080), + wl_display("Unknown-2", 1920, 0, 1920, 1080), + ]; + assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(0), Some(1)]); + } + + #[test] + fn one_output_is_never_claimed_by_two_connectors() { + let drm = [drm_display("DP-1", 1920, 1080), drm_display("DP-2", 1920, 1080)]; + let wl = [ + wl_display("Unknown-1", 0, 0, 1920, 1080), + wl_display("Unknown-2", 1920, 0, 3840, 2160), + ]; + let got = assign_wayland_outputs(&drm, &wl); + assert_eq!(got[0], Some(0)); + assert_ne!(got[0], got[1], "two connectors must not share one output"); + } + + #[test] + fn a_name_match_beats_the_positional_fallback() { + let drm = [drm_display("DP-1", 1920, 1080), drm_display("HDMI-A-1", 1920, 1080)]; + let wl = [ + wl_display("Unknown-1", 0, 0, 1920, 1080), + wl_display("HDMI-1", 1920, 0, 1920, 1080), + ]; + assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(0), Some(1)]); + } + + #[test] + fn extra_connectors_stay_unmatched() { + let drm = [ + drm_display("DP-1", 1920, 1080), + drm_display("DP-2", 1920, 1080), + drm_display("DP-3", 1920, 1080), + ]; + let wl = [ + wl_display("Unknown-1", 0, 0, 1920, 1080), + wl_display("Unknown-2", 1920, 0, 1920, 1080), + ]; + assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(0), Some(1), None]); + } + + #[test] + fn refresh_keeps_a_verdict_through_one_failure_and_gives_it_up_after_a_run() { + assert_eq!(refresh_outcome(Some(3), 0), RefreshOutcome::Publish); + assert_eq!(refresh_outcome(Some(1), 0), RefreshOutcome::Publish); + assert_eq!(refresh_outcome(Some(0), 0), RefreshOutcome::Unavailable); + assert_eq!(refresh_outcome(None, 1), RefreshOutcome::Restamp); + assert_eq!( + refresh_outcome(None, DRM_REFRESH_MAX_FAILURES - 1), + RefreshOutcome::Restamp + ); + assert_eq!( + refresh_outcome(None, DRM_REFRESH_MAX_FAILURES), + RefreshOutcome::GiveUp + ); + assert_eq!( + refresh_outcome(None, DRM_REFRESH_MAX_FAILURES + 5), + RefreshOutcome::GiveUp + ); + } + + #[test] + fn a_dead_producer_stops_being_advertised() { + let mut outcome = RefreshOutcome::Restamp; + for failures in 1..=DRM_REFRESH_MAX_FAILURES { + outcome = refresh_outcome(None, failures); + } + assert_eq!(outcome, RefreshOutcome::GiveUp); + assert!( + DRM_REFRESH_MAX_FAILURES >= 2, + "a single transient failure must never be enough to drop the verdict" + ); + } + + #[test] + fn health_reports_demoted_only_while_the_cooldown_runs() { + let mut h = DisplayHealth::new(); + assert!(!h.demoted(), "a fresh display is not demoted"); + h.zero_frame_streak = DRM_GRAB_MAX_FAILURES - 1; + assert!(!h.demoted(), "one session short of the threshold is not demoted"); + h.zero_frame_streak = DRM_GRAB_MAX_FAILURES; + h.demotes = 1; + assert!(h.demoted(), "at the threshold, inside the cooldown"); + h.since = Instant::now() - demote_cooldown(h.demotes) - Duration::from_secs(1); + assert!(!h.demoted(), "past the cooldown the display must be retried"); + h.demotes = 4; + assert!(h.demoted(), "the backoff must still be holding it at demotion 4"); + } + + #[test] + fn demote_cooldown_doubles_per_cycle_and_caps() { + assert_eq!(demote_cooldown(1), DEMOTE_COOLDOWN); + assert_eq!(demote_cooldown(2), DEMOTE_COOLDOWN * 2); + assert_eq!(demote_cooldown(3), DEMOTE_COOLDOWN * 4); + let cap = DEMOTE_COOLDOWN * (1 << DEMOTE_BACKOFF_MAX_SHIFT); + assert_eq!(demote_cooldown(1 + DEMOTE_BACKOFF_MAX_SHIFT), cap); + assert_eq!(demote_cooldown(50), cap); + assert_eq!(demote_cooldown(u32::MAX), cap); + assert_eq!(demote_cooldown(0), DEMOTE_COOLDOWN); + } + + #[test] + fn a_permanently_ungrabbable_display_stops_churning() { + let burn = Duration::from_secs(5); // four failed sessions + assert!(demote_cooldown(1) + burn < Duration::from_secs(40)); + assert!(demote_cooldown(5) + burn > Duration::from_secs(8 * 60)); + } +} diff --git a/src/server/input_service.rs b/src/server/input_service.rs index 1d4deeb65..aa6893f39 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -396,19 +396,62 @@ fn run_cursor(sp: MouseCursorService, state: &mut StateCursor) -> ResultType<()> if let Some(hcursor) = crate::get_cursor()? { if hcursor != state.hcursor { let msg; + // On the DRM path get_cursor_data() may return a snapshot whose id has advanced past the + // requested `hcursor` (it returns the latest hardware cursor); file it in the cache AND + // record state.hcursor under the id ACTUALLY served, so a later reappearance of that exact + // shape dedupes correctly instead of being suppressed. Everything below is fully + // gated on the drm feature, so the drm-off build stays byte-identical to upstream. + #[cfg(all(target_os = "linux", feature = "drm"))] + let mut drm_served_id = hcursor; if let Some(cached) = state.cached_cursor_data.get(&hcursor) { super::log::trace!("Cursor data cached, hcursor: {}", hcursor); msg = cached.clone(); } else { let mut data = crate::get_cursor_data(hcursor)?; + // File the shape under the id ACTUALLY served, not the one requested. Deliberately a + // NEW name rather than shadowing `hcursor`: the insert below reads as the requested + // id everywhere else in this function, and a cfg-gated shadow would make the two + // builds disagree about what that line means. + #[cfg(all(target_os = "linux", feature = "drm"))] + let served_id = data.id; + #[cfg(all(target_os = "linux", feature = "drm"))] + { + drm_served_id = served_id; + } + #[cfg(all(target_os = "linux", feature = "drm"))] + let cache_key = served_id; + #[cfg(not(all(target_os = "linux", feature = "drm")))] + let cache_key = hcursor; data.colors = hbb_common::compress::compress(&data.colors[..]).into(); let mut tmp = Message::new(); tmp.set_cursor_data(data); msg = Arc::new(tmp); - state.cached_cursor_data.insert(hcursor, msg.clone()); - super::log::trace!("Cursor data updated, hcursor: {}", hcursor); + // A DRM cursor id is derived from the shape's pixels plus geometry, so an animated + // pointer mints a new id on every shape change and this map would grow for the life + // of the service, each entry pinning a compressed cursor message. (Upstream's X11 + // ids come from a small set of XFixes serials, so the map is effectively bounded + // there -- which is why the ceiling is gated and the stock build stays untouched.) + // Past the ceiling, drop the map and start over: the next request for any evicted + // shape just recompresses it, and the ceiling comfortably covers every static shape + // plus a generous animation window. + #[cfg(all(target_os = "linux", feature = "drm"))] + { + const CURSOR_CACHE_MAX: usize = 64; + if state.cached_cursor_data.len() >= CURSOR_CACHE_MAX { + state.cached_cursor_data.clear(); + } + } + state.cached_cursor_data.insert(cache_key, msg.clone()); + super::log::trace!("Cursor data updated, hcursor: {}", cache_key); + } + #[cfg(not(all(target_os = "linux", feature = "drm")))] + { + state.hcursor = hcursor; + } + #[cfg(all(target_os = "linux", feature = "drm"))] + { + state.hcursor = drm_served_id; } - state.hcursor = hcursor; sp.send_shared(msg.clone()); state.cursor_data = msg; } diff --git a/src/server/wayland.rs b/src/server/wayland.rs index dacce9485..ffdf12c98 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -107,8 +107,81 @@ struct CapDisplayInfo { capturer: CapturerPtr, } +/// Set the uinput absolute-pointer range to the whole logical desktop so the compositor maps +/// injected coordinates 1:1 instead of stretching a single-monitor range across all outputs. The +/// PipeWire path does this inline in `check_init`; the DRM path bypasses check_init so it must do it +/// too, otherwise on a multi-monitor host the injected pointer lands on the wrong output — and the +/// hardware cursor, which lives on whichever CRTC the pointer is over, never appears on the captured +/// CRTC (the "cursor not visible" symptom). Reads the layout from the Wayland outputs, so it is +/// independent of the capture backend. +/// +/// This is the DRM path's single copy of what `check_init` does inline for PipeWire, and it does the +/// same three things, for the same reasons: +/// +/// - drops the cached Wayland layout first, because it can predate compositor changes made while no +/// session was active (rustdesk#15601), and on the hotplug path it is stale by definition; +/// - bounds the IPC wait, because `uinput::client::set_resolution` reads its reply with no timeout of +/// its own, so a hung uinput socket would otherwise block every video-service start on this branch +/// and wedge the hotplug worker inside `rt.block_on`, leaving `UINPUT_REFRESH_BUSY` latched true so +/// that every later hotplug refresh is silently skipped for the process lifetime; +/// - records the applied rect and snapshots the per-display layout baseline, which is what arms the +/// #15601 drift remap. Without it the remap never activates on the DRM path at all. +/// +/// It stays a separate copy rather than being folded into `check_init` because `check_init` ships in +/// every Linux build and this feature must not change the drm-off one by so much as a line. +#[cfg(feature = "drm")] +pub(super) async fn update_uinput_resolution() { + if !crate::input_service::wayland_use_uinput() { + return; + } + scrap::wayland::display::clear_wayland_displays_cache(); + let Some(rect) = scrap::wayland::display::get_desktop_rect_for_uinput() else { + log::warn!("Failed to get desktop rect for uinput"); + return; + }; + // Re-snapshot the baseline on every call: this runs at session init and after every hotplug, and + // the baseline is what the client's coordinates are measured against. + let snapshot_layout = || { + super::display_service::set_wayland_layout_baseline( + scrap::wayland::display::get_display_rects_for_uinput(), + ); + }; + // Reprogram the device only when the range actually changes. A display stuck in a rebuild loop + // calls this about once a second, and reapplying an identical range is an IPC roundtrip plus a + // uinput device reconfiguration under a user who may be at the console. + if super::display_service::wayland_uinput_rect() == Some(rect) { + snapshot_layout(); + return; + } + let (minx, maxx, miny, maxy) = rect; + log::info!("update mouse resolution: ({minx}, {maxx}), ({miny}, {maxy})"); + match timeout( + 3_000, + input_service::update_mouse_resolution(minx, maxx, miny, maxy), + ) + .await + { + // Record the rect only after a successful apply, so a transient failure is retried on the + // next call instead of being remembered as applied. + Ok(Ok(())) => { + super::display_service::set_wayland_uinput_rect(rect); + snapshot_layout(); + } + Ok(Err(err)) => log::error!("Failed to update mouse resolution: {}", err), + Err(err) => log::error!("Failed to update mouse resolution: {}", err), + } +} + #[tokio::main(flavor = "current_thread")] pub(super) async fn ensure_inited() -> ResultType<()> { + // DRM/KMS capture (opt-in): the root service owns the reader and the capturer self-inits over + // IPC, so there is no PipeWire recorder to initialize here. But we still must set the uinput + // desktop rect (check_init does this on the PipeWire path, and the DRM path skips check_init). + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + update_uinput_resolution().await; + return Ok(()); + } check_init().await } @@ -116,6 +189,10 @@ pub(super) fn is_inited() -> Option { if is_x11() { None } else { + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + return None; + } if CAP_DISPLAY_INFO.read().unwrap().is_empty() { let mut msg_out = Message::new(); let res = MessageBox { @@ -242,6 +319,24 @@ pub(super) async fn check_init() -> ResultType<()> { } pub(super) async fn get_displays_and_primary() -> ResultType<(Vec, usize)> { + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + // This function runs once per login (update_get_sync_displays_on_login is its only + // caller), and login is the moment the client is PROMISED a display list -- so refresh + // that list over a live `_drm` handshake first. The service wakes sleeping displays and + // answers with the settled truth, which is what makes an unattended box with an idled, + // DISABLED panel connectable at all: the cached list would either omit the panel (probed + // while asleep) or advertise a display with no scanout behind it (probed while awake), and + // either way the wake then firing inside the capture handshake would change the list the + // client had already been given. Properly async, so the executor is never blocked; on any + // failure the cache serves as before. + super::drm_capturer::refresh_displays_for_login().await; + if let Some(displays) = super::drm_capturer::get_display_infos() { + // DRM connector order is not the compositor's primary; resolve the real primary from + // the compositor layout (matched by normalized connector name), not a hardcoded index 0. + return Ok((displays, super::drm_capturer::get_primary_index())); + } + } check_init().await?; // Keep one read guard so clear/reinitialization cannot split these across cache snapshots. let cap_map = CAP_DISPLAY_INFO.read().unwrap(); @@ -260,6 +355,19 @@ pub fn clear() { if is_x11() { return; } + // The DRM path augments its geometry from the compositor's Wayland outputs (logical origin + + // scale), which scrap caches process-wide. The PipeWire path clears that cache on session close, + // but the DRM path opens no PipeWire session, so without this it would keep matching DRM outputs + // against STALE geometry after a monitor hotplug/rotation/scale change. Invalidate it on teardown + // so the next session re-reads fresh geometry (lazily, on the next enumeration) and self-heals. + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + scrap::wayland::display::clear_wayland_displays_cache(); + } + // NOTE: intentionally do NOT reset the DRM probe cache here. `clear()` runs on every capturer + // teardown (which happens on each video-service restart), and re-probing `_drm` from the async + // enumeration path blocks the executor long enough to trip "deadline has elapsed" and spiral + // into a restart loop. DRM availability is fixed at service start, so the cache stays valid. let mut write_lock = CAP_DISPLAY_INFO.write().unwrap(); for (_, addr) in write_lock.iter() { let cap_display_info: *mut CapDisplayInfo = *addr as _; @@ -274,18 +382,136 @@ pub fn clear() { *PIPEWIRE_INITIALIZED.write().unwrap() = false; } +/// Initialize the PipeWire/portal capture path from the plain (sync) video thread, so a DRM display +/// that cannot be captured can fall through to PipeWire for THAT display. `ensure_inited` short-circuits +/// to the DRM branch whenever DRM is globally available, so it never runs `check_init`; this helper +/// drives the same async portal ScreenCast init directly (mirroring `ensure_inited`'s pattern). Needed +/// because `is_available()` is a GLOBAL verdict — it stays true for the still-working DRM outputs — so +/// without a per-display fallback a single failed/demoted DRM display would restart-loop the video +/// service instead of degrading to PipeWire only for itself. +#[cfg(feature = "drm")] +#[tokio::main(flavor = "current_thread")] +async fn ensure_pipewire_inited() -> ResultType<()> { + check_init().await +} + pub(super) fn get_capturer_for_display( display_idx: usize, ) -> ResultType { if is_x11() { bail!("Do not call this function if not wayland"); } + // DRM/KMS capture path: build the capturer straight from the service `_drm` stream, bypassing + // the PipeWire CAP_DISPLAY_INFO machinery entirely. `is_available()` is a GLOBAL verdict, so a + // per-display DRM failure (an ungrabbable/demoted CRTC, or — after the phase-2 split — a + // render-node-absent seat or a convert failure on the unprivileged side) must NOT propagate out + // and restart-loop this per-display video service. Instead fall THROUGH to PipeWire for just this + // display; the other DRM outputs keep streaming over DRM. + // The ONE gate that keeps the probing form on purpose: this runs on the plain video thread, + // not an async executor, and it is the capture-build path, so a definitive verdict is worth + // seconds here. It is also what makes a cold cache recoverable at all -- warm_availability + // gives up after its attempts, so if EVERY gate were cache-only a --server that started + // before the root service would never see DRM again for the rest of its life. + #[cfg(feature = "drm")] + if super::drm_capturer::is_available() { + match super::drm_capturer::get_capturer_info(display_idx) { + Ok(info) => return Ok(info), + Err(e) => { + log::warn!( + "drm capturer for display {} unavailable ({:#}); falling back to PipeWire", + display_idx, + e + ); + ensure_pipewire_inited()?; + } + } + } + // Resolved BEFORE the read guard below, deliberately. `get_display_infos` runs + // `augment_with_wayland_geometry`, which is a compositor output roundtrip, and `clear()` takes + // the WRITE guard on every capturer teardown -- which is exactly what is happening when a DRM + // display is demoted or flapping, i.e. precisely when this path runs. Holding the read guard + // across that roundtrip would stall every concurrent teardown for its duration, and the value + // does not depend on anything inside the guard. + #[cfg(feature = "drm")] + let drm_advertised = if super::drm_capturer::is_available_cached() { + match super::drm_capturer::get_display_infos() { + Some(list) => Some((list.get(display_idx).cloned(), list.len() == 1)), + None => Some((None, false)), + } + } else { + None + }; let cap_map = CAP_DISPLAY_INFO.read().unwrap(); + // Serve ONLY the exact PipeWire entry for this index. Do NOT fall back to another index's + // `CapDisplayInfo`: `CapturerPtr` is a bare `*mut Capturer` cloned by raw-pointer copy, so aliasing + // one entry to two `display_idx` values would let two video-service threads call `frame()` on the + // same `Recorder` with no lock (data race / UB), and it would also mis-map input against the wrong + // rect. DRM and PipeWire do not share an index space (the portal often exposes one whole-desktop + // stream at index 0), so a demoted non-primary DRM index has no PipeWire entry here; that case is + // handled at the source by dropping the demoted display from the advertised list (see + // drm_capturer demotion) so the client re-enumerates against a consistent list, rather than being + // papered over with a shared/mismatched capturer. if let Some(addr) = cap_map.get(&display_idx) { let cap_display_info: *const CapDisplayInfo = *addr as _; unsafe { let cap_display_info = &*cap_display_info; let rect = cap_display_info.rects[cap_display_info.current]; + // Reaching here with DRM active means get_capturer_info bailed (a demoted display) and + // we fell through to PipeWire. Serve this stream ONLY if its rect matches the + // geometry we advertised for this index. The portal typically exposes one whole-desktop + // stream, so on a multi-monitor host that rect is the FULL desktop while the advertised DRM + // geometry is a single connector -> serving it would stretch the frame and offset all + // input. Bail instead; get_display_infos advertised the display offline, so the client + // re-enumerates against a consistent list. A single-display host matches (whole-desktop == + // that display) and is served normally. On a pure-PipeWire host is_available() is false and + // this guard is skipped, preserving upstream behavior exactly. + #[cfg(feature = "drm")] + if let Some((advertised, single_display)) = drm_advertised { + if let Some(advertised) = advertised { + // BOTH SIDES ARE PHYSICAL, so compare them raw. Traced rather than assumed, + // because it was twice "corrected" to a scale conversion that broke it: + // `rect` is built above from `Display::width()/height()`, and the WAYLAND + // variant of those returns `physical_width()/physical_height()` + // (scrap `common/wayland.rs`), i.e. `PipeWireCapturable.physical_size`. + // `try_fix_logical_size` only repairs the capturable's SEPARATE + // `logical_size` field and never touches `physical_size`, so the rect is not + // logical. The advertised DRM geometry is physical too + // (`augment_with_wayland_geometry` sets x/y/scale and deliberately leaves + // width/height as the DRM mode). Dividing one side by the scale therefore + // compares logical against physical and rejects the valid stream on exactly + // the scaled outputs it was meant to rescue. + // + // The size check is what tells one connector apart from the whole-desktop + // rect the portal usually exposes. It is skipped only when BOTH sides say + // there is a single display -- the DRM list has one entry and the PipeWire + // map has one -- because only then is "the whole-desktop stream IS this + // display" true by construction. (The portal can report a different physical + // size for a Full Workspace selection than the connector's mode, which is why + // that case needs the carve-out at all.) The DRM count alone is not enough: + // a monitor on a card the service cannot open is missing from the DRM list + // while the compositor still drives it. + let single_display = single_display && cap_display_info.num == 1; + let consistent = advertised.x == rect.0 .0 + && advertised.y == rect.0 .1 + && (single_display + || (advertised.width as usize == rect.1 + && advertised.height as usize == rect.2)); + if !consistent { + bail!( + "drm display {} demoted with no geometry-consistent PipeWire stream (advertised {}x{}+{}+{} vs stream {}x{}+{}+{}); advertised offline", + display_idx, + advertised.width, + advertised.height, + advertised.x, + advertised.y, + rect.1, + rect.2, + rect.0 .0, + rect.0 .1 + ); + } + } + } Ok(super::video_service::CapturerInfo { origin: rect.0, width: rect.1, From 9a81c8a1383dde703e8b667eef4fc924d52e514e Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:31:09 +0800 Subject: [PATCH 05/72] Drm deb in release workflow (#15776) * docs(agents): add a comment-length rule Comments were growing to document rejected alternatives, past bugs and measurements. That belongs in the commit message, not the source. Co-Authored-By: Claude Opus 5 * ci(drm): build the unattended-wayland deb in the release workflow The deb was built by a separate drm-capture workflow on a plain runner, so it diverged from every other Linux deb: different base, different vcpkg/ffmpeg, different toolchain. Move it into flutter-build.yml as build-rustdesk-linux-drm, mirroring build-rustdesk-linux's x86_64 path -- same ubuntu18.04 container, same vcpkg install, same rust and flutter. libdrmtap is built on the runner first and handed to the container via DRMTAP_PREBUILT_DIR, because bionic's meson is too old to build it. The job is ungated, so the --drm packaging path is exercised on every PR; only publishing stays gated on upload-artifact. drm-capture.yml is deleted along with docs/DRM_CAPTURE_SECURITY.md -- the 29 drm unit tests that workflow ran are no longer executed by CI. Three bugs the move exposed: - build.py anchored the libdrmtap paths on abspath(__file__), which is only cwd-independent on Python >= 3.9 (bpo-20443). The packaging container runs 3.6 and chdir's into flutter/, so the ABI-gate cross-check resolved one directory off and every --drm packaging run would have died with FileNotFoundError. Captured as REPO_ROOT at import instead. - DRMTAP_PREBUILT_DIR no longer needs DRMTAP_ALLOW_UNPINNED. A prebuilt dir inside the repo's own third_party/libdrmtap at the pinned sha is the pinned object, not an override, and is now verified as such. - The variant's Depends carried a bare libdrm2. libdrmtap needs drmModeGetFB2, so it is libdrm2 (>= 2.4.95); below that the package installed and could never capture. The loader also logs the dlerror now instead of discarding it, so a soname or glibc mismatch is named rather than surfacing as a generic "libdrmtap not available". Co-Authored-By: Claude Opus 5 * fix(drm): declare the unattended-wayland deb's real libc6 and libdrm floors libdrmtap is built on the ubuntu-22.04 runner while the rest of the deb comes from the ubuntu18.04 container, so the package has a mixed glibc floor and declared neither half. It installed happily on Ubuntu 20.04 / Debian 11 (glibc 2.31), then dlopen failed on GLIBC_2.34 and capture degraded to the PipeWire portal -- the one thing this variant exists to avoid. Measure the floor off the staged objects and put it in Depends, so apt refuses with a reason instead of handing over a package that can never capture. Measured rather than written down: the number moves whenever either base does, and it lands exactly on RHEL/Rocky 9 (glibc 2.34), where one off-by-one decides whether that whole family can install. drmModeGetFB2 landed in libdrm 2.4.101, not 2.4.95 -- checked against the libdrm tags, xf86drmMode.h first declares it in 2.4.101. The old floor admitted Debian 10 (2.4.97), where the .so is linked -z now and dies on an undefined symbol at dlopen. libdrmtap's own meson.build carries the same wrong number. Upload the deb on always(): the run that fails the drm check is the one whose artifact is most worth downloading. Publish stays gated on success, so an unverified build still cannot reach a release. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 --- .github/workflows/drm-capture.yml | 449 ---------------------------- .github/workflows/flutter-build.yml | 270 +++++++++++++++++ AGENTS.md | 8 + build.py | 86 +++++- docs/DRM_CAPTURE_SECURITY.md | 255 ---------------- libs/scrap/src/common/drm_render.rs | 2 +- libs/scrap/src/common/drmtap_dl.rs | 17 +- 7 files changed, 364 insertions(+), 723 deletions(-) delete mode 100644 .github/workflows/drm-capture.yml delete mode 100644 docs/DRM_CAPTURE_SECURITY.md diff --git a/.github/workflows/drm-capture.yml b/.github/workflows/drm-capture.yml deleted file mode 100644 index 2efc6eabd..000000000 --- a/.github/workflows/drm-capture.yml +++ /dev/null @@ -1,449 +0,0 @@ -name: DRM capture (opt-in drm feature) - -# Least-privilege GITHUB_TOKEN. Every job here only checks out, builds and tests; the artifact -# up/download used by the deb job authenticates with the runtime token, not this one. Declared at -# the workflow level so the reusable bridge workflow called below inherits the same bound. -permissions: - contents: read - -# Supersede a stale run when a PR is pushed again; never cancel a master run, whose whole job is to -# record that a given commit on master was verified. -concurrency: - group: drm-capture-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -# Everything CI-side about the opt-in `drm` backend lives here, so the stock CI and release workflows -# stay byte-identical to a build with the feature off. Nothing in this file runs unless a drm-related -# path changes (or someone dispatches it by hand), so a PR that does not touch the backend pays nothing. -# -# The stock `CI` workflow deliberately does NOT compile with `--features drm`: the shipped default is -# the drm-off configuration and that stays the primary verified one. - -on: - workflow_dispatch: - pull_request: - paths: - - "libs/scrap/src/common/drm_reader.rs" - - "libs/scrap/src/common/drm_render.rs" - - "libs/scrap/src/common/drmtap_dl.rs" - - "libs/scrap/src/common/mod.rs" - - "libs/scrap/Cargo.toml" - # The ROOT manifest is where the top-level `drm` feature is DEFINED, so a PR that changes - # what `drm` pulls in must not skip this workflow. `Cargo.lock` is deliberately NOT here: - # measured over the last 100 commits, it alone would have fired this workflow 13 times and - # the pair 24 times, each run costing about two job-hours for a full vcpkg + flutter release - # build, almost always for a dependency the drm path never touches. A lockfile bump that - # does affect it arrives with a manifest or source change, which is triggered above. - - "Cargo.toml" - - "src/ipc.rs" - - "src/ipc/**" - - "src/server/drm_capturer.rs" - - "src/server/wayland.rs" - - "src/server/display_service.rs" - # These three carry DRM wiring too (warm_availability, the cursor path in run_cursor, and the - # producer start + get_cursor/get_cursor_data), so a PR touching only them must not skip the - # whole drm verification. - - "src/server.rs" - - "src/server/input_service.rs" - - "src/platform/linux.rs" - - "build.py" - - ".github/workflows/drm-capture.yml" - push: - branches: - - master - # Deliberately the SAME list as the pull_request trigger above: a shorter one here means a push - # that touches only the missing paths (a squash merge, a direct push) skips re-verification. - paths: - - "libs/scrap/src/common/drm_reader.rs" - - "libs/scrap/src/common/drm_render.rs" - - "libs/scrap/src/common/drmtap_dl.rs" - - "libs/scrap/src/common/mod.rs" - - "libs/scrap/Cargo.toml" - # The ROOT manifest is where the top-level `drm` feature is DEFINED, so a PR that changes - # what `drm` pulls in must not skip this workflow. `Cargo.lock` is deliberately NOT here: - # measured over the last 100 commits, it alone would have fired this workflow 13 times and - # the pair 24 times, each run costing about two job-hours for a full vcpkg + flutter release - # build, almost always for a dependency the drm path never touches. A lockfile bump that - # does affect it arrives with a manifest or source change, which is triggered above. - - "Cargo.toml" - - "src/ipc.rs" - - "src/ipc/**" - - "src/server/drm_capturer.rs" - - "src/server/wayland.rs" - - "src/server/display_service.rs" - # These three carry DRM wiring too (warm_availability, the cursor path in run_cursor, and the - # producer start + get_cursor/get_cursor_data), so a PR touching only them must not skip the - # whole drm verification. - - "src/server.rs" - - "src/server/input_service.rs" - - "src/platform/linux.rs" - - "build.py" - - ".github/workflows/drm-capture.yml" - -env: - VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" - FLUTTER_VERSION: "3.24.5" - -jobs: - drm-tests: - name: drm unit tests (linux) - runs-on: ubuntu-24.04 - timeout-minutes: 60 - steps: - - name: Free Disk Space (Ubuntu) - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 - with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: false - swap-storage: false - - - name: Checkout source code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - submodules: recursive - persist-credentials: false - - - name: Install prerequisites - shell: bash - run: | - sudo apt-get -y update - sudo apt-get install -y \ - clang cmake curl gcc git g++ \ - libpam0g-dev libasound2-dev libunwind-dev \ - libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ - libgtk-3-dev libpulse-dev libva-dev libvdpau-dev \ - libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ - libxdo-dev libxfixes-dev nasm wget - - - name: Setup vcpkg with Github Actions binary cache - uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 - with: - vcpkgDirectory: /opt/artifacts/vcpkg - vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} - - - name: Install vcpkg dependencies - shell: bash - run: $VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed" - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 - with: - toolchain: stable - targets: x86_64-unknown-linux-gnu - - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - - # The whole rustdesk-crate test set with the feature ON, not just the `_drm` ones by name: a name - # filter would skip the sibling asserts that also matter in this configuration, notably the one - # bounding `size_of::()`, which the new DmabufDesc variant grows. - # The two skips are the same ones the stock CI applies: both need a real display server and fail - # on a headless runner regardless of this feature. - - name: Run rustdesk crate tests with the drm feature - shell: bash - run: | - cargo test --locked --target x86_64-unknown-linux-gnu -p rustdesk --features drm \ - --no-fail-fast -- --skip test_get_cursor_pos --skip test_get_key_state - - # The capture backend itself lives in the scrap crate, so its unit tests are a separate - # package. `--lib` keeps this to unit tests; none of them touch a device or a display server. - - name: Run scrap crate tests with the drm feature - shell: bash - run: | - cargo test --locked --target x86_64-unknown-linux-gnu -p scrap --features drm --lib - - libdrmtap: - name: libdrmtap pin, build and .so contract - runs-on: ubuntu-24.04 - timeout-minutes: 60 - steps: - - name: Checkout source code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - - name: Install libdrmtap build deps - shell: bash - run: | - sudo apt-get -y update - sudo apt-get install -y meson ninja-build pkg-config libdrm-dev \ - libegl1-mesa-dev libgles2-mesa-dev - - # Exercises the real fetch-and-build path in build.py, which pins the commit by sha, so a bad or - # moved pin fails here rather than in a release job. - - name: Fetch the pinned libdrmtap and build the .so - shell: bash - run: | - python3 - <<'PY' - import importlib.util, sys - spec = importlib.util.spec_from_file_location("b", "build.py") - b = importlib.util.module_from_spec(spec) - sys.argv = ["build.py"] - spec.loader.exec_module(b) - so = b.build_libdrmtap_so() - print(f"::notice::built {so}") - open("so_path", "w").write(so) - PY - - # The shipped hot path is the EGL detile. libdrmtap degrades to a CPU-only stub when the egl or - # glesv2 pkg-config files are missing on the build host, and nothing else in the pipeline notices, - # so assert here that the object we would ship really carries EGL and really exports every symbol - # the runtime loader resolves. - - name: Assert the .so contract (EGL enabled, loader symbols present) - shell: bash - run: | - # Strict mode is load-bearing here: without it the trailing ::notice echo would return 0 - # and mask the `test "$missing" -eq 0` assertion, so the step would pass with a missing - # loader symbol or a CPU-only stub. (pipefail also keeps the grep -c pipelines honest.) - set -euo pipefail - SO="$(cat so_path)" - echo "checking $SO" - missing=0 - # Every symbol drmtap_dl.rs resolves, derived from the loader itself so the two cannot - # drift. The character class allows digits (a drmtap_grab_desc2 would otherwise be - # silently dropped from the loop), and the count is asserted below so a refactor of the - # loader away from b"..." literals cannot quietly turn this whole check into a no-op that - # iterates zero times and passes. - # `|| true` on the extraction pipelines: under set -e/pipefail a zero-match grep would - # abort the script before the explicit ::error guard below can say WHY it failed; the - # guard on nsyms is the intended reporter for that case. - syms=$(grep -oE 'b"drmtap_[a-z0-9_]+"' libs/scrap/src/common/drmtap_dl.rs \ - | sed 's/^b"//; s/"$//' | sort -u || true) - nsyms=$(echo "$syms" | grep -c . || true) - if [ "$nsyms" -lt 13 ]; then - echo "::error::extracted only $nsyms loader symbols from drmtap_dl.rs (expected >= 13); the extraction pattern no longer matches the loader" - missing=1 - fi - # Inspect the object ONCE into a variable, then match with bash's own pattern operator -- - # NO PIPE ANYWHERE IN THESE CHECKS. `anything | grep -q` under `set -o pipefail` reports a - # FALSE FAILURE as soon as the producer outruns the 64 KB pipe buffer: grep -q exits at the - # first match, the producer dies on SIGPIPE (141), and pipefail makes that the pipeline's - # status, so a library that HAS the symbol is reported as missing it. Measured on a real - # EGL-enabled .so (101 KB of `strings`, both markers present): the piped form reported both - # missing and failed the step. Note the obvious repair does NOT work -- materializing the - # output and then doing `printf '%s\n' "$var" | grep -q` keeps the pipe and just swaps the - # producer, and it fails identically (measured). Today's release-sized .so happens to fit in - # the buffer, which is the only reason this has not fired yet. - exported="$(nm -D --defined-only "$SO")" - strs="$(strings "$SO")" - for sym in $syms; do - # Line-anchored: wrap in newlines so the pattern can require a whole line, the same - # thing `grep " T $sym$"` was expressing. - if [[ $'\n'"$exported"$'\n' != *$'\n'*" T $sym"$'\n'* ]]; then - echo "::error::libdrmtap does not export $sym, which the runtime loader resolves" - missing=1 - fi - done - # EGL is reached by lazy dlopen, on purpose, so that the privileged process never links the - # vendor GL stack. That means there is NO DT_NEEDED entry and no undefined egl* symbol to look - # for: the naive ELF check reports "no EGL" on a perfectly good library. What a CPU-only stub - # build really lacks is the dlopen target name and the import call itself. - for s in "libEGL.so.1" "eglCreateImageKHR"; do - if [[ "$strs" != *"$s"* ]]; then - echo "::error::libdrmtap looks like a CPU-only stub (no $s): the EGL detile hot path is missing" - missing=1 - fi - done - test "$missing" -eq 0 - echo "::notice::libdrmtap .so contract ok ($nsyms loader symbols, EGL detile present)" - - # The bridge generator is a reusable workflow, so this calls the stock one instead of duplicating it. - generate-bridge: - uses: ./.github/workflows/bridge.yml - - drm-deb: - name: unattended-wayland deb (verification build) - needs: generate-bridge - runs-on: ubuntu-24.04 - timeout-minutes: 60 - steps: - - name: Free Disk Space (Ubuntu) - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 - with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: false - swap-storage: false - - - name: Checkout source code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - submodules: recursive - persist-credentials: false - - - name: Restore bridge files - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: bridge-artifact - path: ./ - - - name: Install prerequisites - shell: bash - run: | - sudo apt-get -y update - # Same list the stock linux job needs, plus the flutter desktop toolchain and the three - # libdrmtap build deps (libdrm and the mesa-specific EGL/GLES dev packages). - sudo apt-get install -y \ - clang cmake curl gcc git g++ ninja-build meson pkg-config \ - libpam0g-dev libasound2-dev libunwind-dev liblzma-dev \ - libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ - libgtk-3-dev libpulse-dev libva-dev libvdpau-dev \ - libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ - libxdo-dev libxfixes-dev nasm wget \ - libdrm-dev libegl1-mesa-dev libgles2-mesa-dev - - - name: Setup vcpkg with Github Actions binary cache - uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 - with: - vcpkgDirectory: /opt/artifacts/vcpkg - vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} - - - name: Install vcpkg dependencies - shell: bash - run: $VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed" - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 - with: - toolchain: stable - targets: x86_64-unknown-linux-gnu - - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - - - name: Setup flutter - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 - with: - channel: "stable" - flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Patch flutter - shell: bash - run: | - cd $(dirname $(dirname $(which flutter))) - # `[[ ... ]] && cmd` as the last line makes the STEP fail once FLUTTER_VERSION moves off - # the pinned value, because the failed test becomes the script's exit status. An explicit - # if/else skips instead. Reading the values from the environment rather than interpolating - # github expressions into the script also keeps this off zizmor's template-injection list. - # (spelled out in prose: a literal expression marker here, even in a comment, is parsed by - # actionlint and breaks workflow linting.) - if [[ "$FLUTTER_VERSION" == "3.24.5" ]]; then - git apply "$GITHUB_WORKSPACE/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff" - else - echo "::notice::flutter $FLUTTER_VERSION is not 3.24.5; skipping the dropdown patch" - fi - - - name: Build the unattended-wayland deb - shell: bash - run: | - set -euo pipefail - # The features have to be on the cargo line HERE, because the packaging line below passes - # --skip-cargo and never rebuilds: whatever this compiles is what ships. ASK build.py for - # the list rather than repeating it -- get_features() is the single definition of what - # these flags mean, and a hardcoded copy silently ships something other than what - # `build.py --drm` produces the moment that function changes. The flags must be the same - # on both lines for that to hold, so keep them in one variable. - DRM_BUILD_FLAGS=(--flutter --drm --hwcodec --unix-file-copy-paste) - FEATURES="$(python3 ./build.py "${DRM_BUILD_FLAGS[@]}" --print-features)" - echo "features from build.py: $FEATURES" - # Assert rather than trust: an empty or error-shaped value would otherwise become a cargo - # line that builds a stock binary, which only the staged-binary marker check would catch. - # Match whole comma-separated TOKENS, one feature at a time. A substring test would depend - # on the order get_features happens to append them (failing a correct build the day they - # are reordered) and would also match a future feature that merely contains "drm", the same - # trap build.py avoids by splitting on commas rather than testing a substring. - for want in drm drm-wake; do - case ",$FEATURES," in - *",$want,"*) ;; - *) echo "::error::build.py --print-features returned no '$want' feature: $FEATURES"; exit 1 ;; - esac - done - cargo build --locked --lib --release --features "$FEATURES" - python3 ./build.py "${DRM_BUILD_FLAGS[@]}" --skip-cargo - - # build.py exits 0 on some inner failures, so assert the artifact instead of trusting the status, - # and assert the two things that make it the drm variant at all. - - name: Assert the deb is a real drm build - shell: bash - run: | - # Strict mode so the mid-script checks can fail the step (without it only the LAST - # command's status counts and the greps above it are decorative). - set -euo pipefail - # Glob into an array and assert the COUNT. `deb="$(ls ...)"` aborted on zero matches - # before its own `test -n` could report, and on several matches produced a multi-line - # value whose `mv` failed with something unrelated to the real problem. - shopt -s nullglob - debs=(rustdesk-unattended-wayland-*.deb) - if [ "${#debs[@]}" -ne 1 ]; then - echo "::error::expected exactly one rustdesk-unattended-wayland-*.deb, found ${#debs[@]}: ${debs[*]-none}" - exit 1 - fi - deb="${debs[0]}" - echo "::notice::built $deb ($(stat -c %s "$deb") bytes)" - # Pipe-free for the same reason as the .so contract step above (see the comment there: - # a producer feeding a grep that can exit early is a SIGPIPE reported as a failure under - # pipefail). `grep -E` without -q reads to EOF so these two happen to be safe, but the - # shape is the hazard and the next `-q` added here would inherit it silently. - contents="$(dpkg -c "$deb")" - if [[ ! "$contents" =~ usr/lib/rustdesk/libdrmtap\.so\.0\.[0-9]+\.[0-9]+ ]]; then - echo "::error::the deb does not contain a versioned libdrmtap.so.0.x.y" - exit 1 - fi - if [[ "$contents" != *"usr/lib/rustdesk/libdrmtap.so.0 ->"* ]]; then - echo "::error::the deb does not contain the libdrmtap.so.0 soname symlink" - exit 1 - fi - # The library alone does not make this a drm build: build.py stages it whenever --drm is - # passed, independently of what was compiled, and the deb name is what tells a user this - # is the consent-bypass variant. Assert the BINARY too, by the absolute dlopen path that - # only exists when the feature is compiled in -- otherwise a stock binary could ship - # under the unattended-wayland name with a library it can never reach. - rm -rf /tmp/debassert && dpkg-deb -R "$deb" /tmp/debassert - if ! grep -qF /usr/lib/rustdesk/libdrmtap.so.0 /tmp/debassert/usr/share/rustdesk/lib/librustdesk.so; then - echo "::error::the packaged librustdesk.so has no libdrmtap dlopen path; this is not a drm build" - exit 1 - fi - mv "$deb" "${deb%.deb}-x86_64.deb" - - # MEASURE the glibc floor rather than describing it. This job builds on the runner instead of the - # ubuntu18.04 container the stock release debs use, so the artifact only runs on a host at least - # as new as the runner -- and that number belongs in the artifact NAME, because a comment in this - # file is not visible to whoever downloads it from the Actions UI. - - name: Measure the deb glibc floor - id: floor - shell: bash - run: | - # Strict mode for the same reason as the assert step above. The floor extraction gets an - # explicit rescue so a no-match grep reaches the `test -n` reporter instead of dying as a - # bare pipeline failure. - set -euo pipefail - # Same nullglob array + count assertion as the assert step above, for the same two - # reasons: under set -e a zero-match `ls` aborts before anything can report WHY, and - # several matches make `deb` multi-line so dpkg-deb fails with an unrelated error. (This - # was the sibling left behind when that one was fixed.) - shopt -s nullglob - debs=(rustdesk-unattended-wayland-*-x86_64.deb) - if [ "${#debs[@]}" -ne 1 ]; then - echo "::error::expected exactly one renamed deb to measure, found ${#debs[@]}: ${debs[*]-none}" - exit 1 - fi - deb="${debs[0]}" - rm -rf /tmp/debfloor && dpkg-deb -R "$deb" /tmp/debfloor - floor="$(objdump -T /tmp/debfloor/usr/share/rustdesk/lib/librustdesk.so \ - | grep -oE 'GLIBC_2\.[0-9]+' | sort -uV | tail -1 || true)" - test -n "$floor" - echo "floor=${floor#GLIBC_}" >> "$GITHUB_OUTPUT" - echo "::notice::deb requires ${floor} or newer (built on the runner, not the ubuntu18.04 release container)" - - # Verification artifact, deliberately NOT a release deliverable. The consent-free variant stays - # out of the published release either way; the name states the floor so nobody installs it on an - # older distro and hits a bare loader error. - - name: Upload the deb - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: rustdesk-unattended-wayland-x86_64-verification-glibc${{ steps.floor.outputs.floor }}.deb - path: rustdesk-unattended-wayland-*-x86_64.deb diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 63526f95e..95cfdd8e3 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -1749,6 +1749,276 @@ jobs: files: | res/rustdesk-${{ env.VERSION }}*.zst + # Same build as build-rustdesk-linux x86_64 -- same vcpkg/ffmpeg, same ubuntu18.04 container, same + # rust and flutter -- only with the drm feature on, so it ships as the separate + # rustdesk-unattended-wayland deb. libdrmtap is built on the runner because bionic's meson is too + # old for it. A separate job rather than a matrix entry of build-rustdesk-linux: appimage and + # flatpak need that job, and a failure here must not skip them. + build-rustdesk-linux-drm: + needs: [generate-bridge] + name: build rustdesk linux drm x86_64 + runs-on: ubuntu-22.04 + steps: + - name: Export GitHub Actions cache environment variables + uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6 + with: + script: | + core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + + - name: Maximize build space + run: | + sudo rm -rf /opt/ghc + sudo rm -rf /usr/local/lib/android + sudo rm -rf /usr/share/dotnet + sudo apt-get update -y + sudo apt-get install -y nasm + sudo apt-get install -y qemu-user-static + + - name: Checkout source code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + + - name: Set Swap Space + uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0 + with: + swap-size-gb: 12 + + - name: Free Space + run: | + df -h + free -m + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: ${{ env.RUST_VERSION }} + targets: x86_64-unknown-linux-gnu + components: "rustfmt" + + - name: Save Rust toolchain version + run: | + RUST_TOOLCHAIN_VERSION=$(cargo --version | awk '{print $2}') + echo "RUST_TOOLCHAIN_VERSION=$RUST_TOOLCHAIN_VERSION" >> $GITHUB_ENV + + - name: Disable rust bridge build + run: | + # only build cdylib + sed -i "s/\[\"cdylib\", \"staticlib\", \"rlib\"\]/\[\"cdylib\"\]/g" Cargo.toml + + - name: Restore bridge files + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bridge-artifact + path: ./ + + - name: Setup vcpkg with Github Actions binary cache + uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 + with: + vcpkgDirectory: /opt/artifacts/vcpkg + vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} + doNotCache: false + + - name: Install vcpkg dependencies + run: | + sudo apt install -y libva-dev && apt show libva-dev + if ! $VCPKG_ROOT/vcpkg \ + install \ + --triplet x64-linux \ + --x-install-root="$VCPKG_ROOT/installed"; then + find "${VCPKG_ROOT}/" -name "*.log" | while read -r _1; do + echo "$_1:" + echo "======" + cat "$_1" + echo "======" + echo "" + done + exit 1 + fi + head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-x64-linux-rel-out.log" || true + shell: bash + + # The container's meson is too old to build libdrmtap, so build it here from the pin in + # build.py and hand the .so to the container below via DRMTAP_PREBUILT_DIR. + - name: Build libdrmtap + run: | + sudo apt-get install -y meson ninja-build pkg-config \ + libdrm-dev libegl1-mesa-dev libgles2-mesa-dev + python3 - <<'PY' + import importlib.util, sys + spec = importlib.util.spec_from_file_location("b", "build.py") + b = importlib.util.module_from_spec(spec) + sys.argv = ["build.py"] + spec.loader.exec_module(b) + print(f"::notice::built {b.build_libdrmtap_so()}") + PY + shell: bash + + - uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26 + name: Build rustdesk + id: vcpkg + with: + arch: x86_64 + distro: ubuntu18.04 + githubToken: ${{ github.token }} + setup: | + ls -l "${PWD}" + ls -l /opt/artifacts/vcpkg/installed + dockerRunArgs: | + --volume "${PWD}:/workspace" + --volume "/opt/artifacts:/opt/artifacts" + shell: /bin/bash + install: | + apt-get update -y + echo -e "installing deps" + apt-get install -y \ + build-essential \ + clang \ + cmake \ + curl \ + gcc \ + git \ + g++ \ + libayatana-appindicator3-dev \ + libasound2-dev \ + libclang-10-dev \ + libgstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev \ + libgtk-3-dev \ + libpam0g-dev \ + libpulse-dev \ + libva-dev \ + libxcb-randr0-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + libxdo-dev \ + libxfixes-dev \ + llvm-10-dev \ + nasm \ + ninja-build \ + pkg-config \ + tree \ + python3 \ + rpm \ + unzip \ + wget \ + xz-utils \ + libssl-dev + # we have libopus compiled by us. + apt-get remove -y libopus-dev || true + # output devs + ls -l ./ + tree -L 3 /opt/artifacts/vcpkg/installed + run: | + # disable git safe.directory + git config --global --add safe.directory "*" + # rust + pushd /opt + # do not use rustup, because memory overflow in qemu + wget -O rust.tar.gz https://static.rust-lang.org/dist/rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu.tar.gz + tar -zxvf rust.tar.gz > /dev/null && rm rust.tar.gz + cd rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu && ./install.sh + rm -rf rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu + # edit config + mkdir -p ~/.cargo/ + echo """ + [source.crates-io] + registry = 'https://github.com/rust-lang/crates.io-index' + """ > ~/.cargo/config + cat ~/.cargo/config + # start build + pushd /workspace + export VCPKG_ROOT=/opt/artifacts/vcpkg + # use the .so built on the runner; build.py checks it is the pinned checkout + export DRMTAP_PREBUILT_DIR=/workspace/third_party/libdrmtap/build-pkg + # ask build.py for the features so this line and the packaging line cannot drift + FEATURES=$(python3 ./build.py --flutter --drm --hwcodec --unix-file-copy-paste --print-features) + # an empty or error-shaped value would silently build a stock binary + for want in drm drm-wake; do + case ",$FEATURES," in + *",$want,"*) ;; + *) echo "::error::build.py returned no '$want' feature: $FEATURES"; exit 1 ;; + esac + done + cargo build --locked --lib --features "$FEATURES" --release + rm -rf target/release/deps target/release/build + rm -rf ~/.cargo + + # Setup Flutter + # disable git safe.directory + git config --global --add safe.directory "*" + export PATH=/opt/flutter/bin:$PATH + pushd /opt + wget https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz + tar xf flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz + flutter doctor -v + + if [[ "3.24.5" == ${{ env.FLUTTER_VERSION }} ]]; then + pushd /opt/flutter + git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + popd + fi + + # build flutter + pushd /workspace + export CARGO_INCREMENTAL=0 + export DEB_ARCH=amd64 + python3 ./build.py --flutter --drm --hwcodec --unix-file-copy-paste --skip-cargo + for name in rustdesk*??.deb; do + mv "$name" "${name%%.deb}-x86_64.deb" + done + + # build.py can exit 0 on some inner failures, so check the artifact rather than the status. + # The package name is the informed consent for consent-free capture, so a stock binary must + # never ship under it: assert the bundled library AND the dlopen path in the binary. + - name: Check the deb is a drm build + run: | + set -euo pipefail + # Resolve by glob, not from env.VERSION: build.py names the deb from Cargo.toml, so a + # hardcoded name fails with a bare exit 1 the first time those two drift. + shopt -s nullglob + debs=(rustdesk-unattended-wayland-*-x86_64.deb) + if [ "${#debs[@]}" -ne 1 ]; then + echo "::error::expected one rustdesk-unattended-wayland-*-x86_64.deb, found ${#debs[@]}: ${debs[*]-none}" + exit 1 + fi + deb="${debs[0]}" + echo "DRM_DEB=$deb" >> "$GITHUB_ENV" + contents="$(dpkg -c "$deb")" + if [[ ! "$contents" =~ usr/lib/rustdesk/libdrmtap\.so\.0\.[0-9]+\.[0-9]+ ]]; then + echo "::error::$deb has no versioned libdrmtap.so.0.x.y" + exit 1 + fi + if [[ "$contents" != *"usr/lib/rustdesk/libdrmtap.so.0 ->"* ]]; then + echo "::error::$deb has no libdrmtap.so.0 soname symlink" + exit 1 + fi + rm -rf /tmp/deb && dpkg-deb -R "$deb" /tmp/deb + if ! grep -qF /usr/lib/rustdesk/libdrmtap.so.0 /tmp/deb/usr/share/rustdesk/lib/librustdesk.so; then + echo "::error::$deb was not built with the drm feature" + exit 1 + fi + shell: bash + + - name: Publish debian package + if: env.UPLOAD_ARTIFACT == 'true' + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 + with: + prerelease: true + tag_name: ${{ env.TAG_NAME }} + files: | + ${{ env.DRM_DEB }} + + # No UPLOAD_ARTIFACT gate: on a PR this is the only way to get at the deb that was just built. + # always(), because a deb that failed the check above is the one most worth downloading. + - name: Upload deb + if: always() && env.DRM_DEB != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ env.DRM_DEB }} + path: ${{ env.DRM_DEB }} + build-rustdesk-linux-sciter: if: ${{ inputs.upload-artifact }} runs-on: ${{ matrix.job.on }} diff --git a/AGENTS.md b/AGENTS.md index 8f558c959..4ff5b1e75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,14 @@ * Do not make formatting-only changes. * Keep naming/style consistent with nearby code. +### Comments + +* Keep them short: one line by default, three at most. +* Say **why**, never what. If the code already says it, delete the comment. +* Do not document rejected alternatives, past bugs, measurements, or how you arrived at the code. That belongs in the commit message or the PR. +* A comment must never be longer than the code it describes. +* Applies to YAML, shell and Python too, not just Rust. + ### Be minimally invasive * Prefer purely additive changes: layer new (`#[cfg]`-gated) blocks or new functions around existing code instead of restructuring it. The ideal diff for a fix adds lines and modifies/deletes none. diff --git a/build.py b/build.py index 9ebcf0eba..b32e95672 100755 --- a/build.py +++ b/build.py @@ -15,6 +15,11 @@ import argparse import sys from pathlib import Path +# Captured at import, while cwd is still the repo root: before Python 3.9 the main script's __file__ +# stays relative (bpo-20443), so abspath() re-resolves it against the cwd -- and the ubuntu18.04 +# packaging container runs 3.6 and chdir's into flutter/ before it reaches the libdrmtap code. +REPO_ROOT = os.path.dirname(os.path.abspath(__file__)) + windows = platform.platform().startswith('Windows') osx = platform.platform().startswith( 'Darwin') or platform.platform().startswith("macOS") @@ -327,8 +332,8 @@ def get_features(args): # straight from `target/release` without bundling libdrmtap, without the rename, without # Conflicts/Provides and without assert_staged_binary_is_drm() -- so they would emit a # package NAMED `rustdesk` carrying the consent-bypass backend and the root-side uinput - # injection. The separate package name is the informed consent this feature rests on (see - # docs/DRM_CAPTURE_SECURITY.md), so refuse rather than ship a stock-named build of it. + # injection. The separate package name is the informed consent this feature rests on, so + # refuse rather than ship a stock-named build of it. branch = linux_packaging_branch() if branch != 'deb': raise Exception( @@ -399,20 +404,40 @@ LIBDRMTAP_SHA = os.environ.get('DRMTAP_SHA', LIBDRMTAP_SHA_PINNED) DRMTAP_UNPINNED_OK = os.environ.get('DRMTAP_ALLOW_UNPINNED') == '1' +def _prebuilt_dir_is_the_pinned_checkout(prebuilt_dir): + # A .so built from this repo's own third_party/libdrmtap at the pinned sha is the pinned object, + # not an override, so it must not need the opt-in. This is how CI hands the library from a step + # that has meson to a packaging container that does not. + src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap') + try: + inside = os.path.commonpath([os.path.abspath(prebuilt_dir), src]) == src + except ValueError: + return False + if not inside or not os.path.isdir(os.path.join(src, '.git')): + return False + try: + head = subprocess.check_output(['git', '-C', src, 'rev-parse', 'HEAD']).decode().strip() + except (subprocess.SubprocessError, OSError): + return False + return head == LIBDRMTAP_SHA + + def _validate_libdrmtap_pin(): # Called from build_libdrmtap_so(), NOT at import: a stock (non --drm) build must stay # byte-identical to upstream in behaviour too, and leftover DRMTAP_* variables in the # environment (or a malformed sha) must not be able to fail a build that never touches # libdrmtap. + # `or None` so an empty value reads as unset here exactly as it does in build_libdrmtap_so(), + # which tests it for truthiness. + prebuilt = os.environ.get('DRMTAP_PREBUILT_DIR') or None + if prebuilt and _prebuilt_dir_is_the_pinned_checkout(prebuilt): + prebuilt = None overridden = [ name for name, value, pinned in ( ('DRMTAP_REPO', LIBDRMTAP_REPO, LIBDRMTAP_REPO_PINNED), ('DRMTAP_SHA', LIBDRMTAP_SHA, LIBDRMTAP_SHA_PINNED), - # `or None` so an empty value reads as unset here exactly as it does in - # build_libdrmtap_so(), which tests it for truthiness. Otherwise `DRMTAP_PREBUILT_DIR=` - # would demand the opt-in for an override that is not going to happen. - ('DRMTAP_PREBUILT_DIR', os.environ.get('DRMTAP_PREBUILT_DIR') or None, None), + ('DRMTAP_PREBUILT_DIR', prebuilt, None), ) if value != pinned ] @@ -452,7 +477,6 @@ def build_libdrmtap_so(): # library target is built (the source also carries a helper binary we do not # ship). Returns the path to the built versioned .so (e.g. libdrmtap.so.0.4.x). _validate_libdrmtap_pin() - repo_root = os.path.dirname(os.path.abspath(__file__)) # Allow a caller (e.g. CI) to build the .so ahead of time and hand it in via # DRMTAP_PREBUILT_DIR (must contain the real libdrmtap.so.0.* object). prebuilt_dir = os.environ.get('DRMTAP_PREBUILT_DIR') @@ -474,7 +498,7 @@ def build_libdrmtap_so(): # `main` the pinned commit is not in the shallow clone at all and the build fails on an unreachable # object. Fetching the sha needs no branch name, so it keeps working across every upstream push and # is immune to a ref being moved or repointed. - src = os.path.join(repo_root, 'third_party', 'libdrmtap') + src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap') if not os.path.exists(os.path.join(src, 'meson.build')): if os.path.isdir(src): shutil.rmtree(src) @@ -526,7 +550,7 @@ def _assert_so_has_egl(so_path): # EGL is reached by lazy dlopen, on purpose, so that the privileged service never links the GPU # stack. That means there is no DT_NEEDED to look for and an ELF-level check reports "no EGL" on a # perfectly good library; the dlopen name and an extension symbol are what a CPU-only stub really - # lacks. Same two markers the drm-capture workflow asserts in CI. + # lacks. try: with open(so_path, 'rb') as f: blob = f.read() @@ -566,11 +590,8 @@ def assert_so_satisfies_the_runtime_abi_gate(so_path): print(f'[drm] cannot read a version out of {so_path}; skipping the ABI-gate cross-check') return so_ver = tuple(int(g) for g in m.groups()) - # Anchored on THIS file, not on the cwd: both callers of stage_libdrmtap_into_deb have already - # chdir'd into flutter/ by the time they get here, so a cwd-relative path raises FileNotFoundError - # and fails every --drm packaging run. (It did; CI caught it.) - gate_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), - 'libs', 'scrap', 'src', 'common', 'drmtap_dl.rs') + # REPO_ROOT, not abspath(__file__): both callers have chdir'd into flutter/ by now. + gate_path = os.path.join(REPO_ROOT, 'libs', 'scrap', 'src', 'common', 'drmtap_dl.rs') with open(gate_path) as f: gate_src = f.read() @@ -613,6 +634,37 @@ def stage_libdrmtap_into_deb(so_path): system2(f'ln -sf "{so_basename}" tmpdeb/usr/lib/rustdesk/libdrmtap.so.0') +def _max_glibc_minor(path): + # Read from .dynstr rather than via objdump so packaging needs no binutils; chunked because + # librustdesk.so is ~45 MB. + best = 0 + with open(path, 'rb') as f: + tail = b'' + while True: + chunk = f.read(1 << 20) + if not chunk: + return best + for m in re.finditer(rb'GLIBC_2\.(\d+)', tail + chunk): + best = max(best, int(m.group(1))) + tail = chunk[-16:] + + +def measured_glibc_floor(): + # libdrmtap is built on a newer base than the rest of the deb, so the floor is whichever staged + # object is higher -- and it moves whenever either base does. + paths = [p for p in glob.glob('tmpdeb/usr/lib/rustdesk/libdrmtap.so.0.*') + + glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so') + + glob.glob('tmpdeb/usr/share/rustdesk/rustdesk') + if os.path.isfile(p) and not os.path.islink(p)] + minor = max((_max_glibc_minor(p) for p in paths), default=0) + if not minor: + raise Exception( + f'could not measure a GLIBC_2.x floor from any staged object ({paths or "none found"}); ' + 'refusing to ship the unattended-wayland variant with an undeclared libc6 floor, which ' + 'is what lets it install on a host where libdrmtap can never load') + return f'2.{minor}' + + def retarget_control_to_drm_variant(): # Rewrite the control file that generate_control_file just produced, instead of parameterizing that # function: the stock packaging path stays exactly as upstream wrote it, and everything specific to @@ -620,6 +672,8 @@ def retarget_control_to_drm_variant(): # conflict with and replace it: you install one or the other, never both. It also needs libdrmtap's # own runtime deps, which the stock package has no reason to carry. path = '../res/DEBIAN/control' + floor = measured_glibc_floor() + print(f'[drm] {DRM_PACKAGE_NAME} libc6 floor measured at {floor}') with open(path) as f: lines = f.readlines() out = [] @@ -628,7 +682,9 @@ def retarget_control_to_drm_variant(): out.append(f'Package: {DRM_PACKAGE_NAME}\n') out.append('Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n') elif line.startswith('Depends:'): - out.append(line.rstrip('\n') + ', libdrm2, libegl1, libgles2\n') + # 2.4.101 is where drmModeGetFB2 landed; below it libdrmtap loads and can never capture. + out.append(line.rstrip('\n') + ', libdrm2 (>= 2.4.101), libegl1, libgles2, ' + f'libc6 (>= {floor})\n') else: out.append(line) body = ''.join(out) diff --git a/docs/DRM_CAPTURE_SECURITY.md b/docs/DRM_CAPTURE_SECURITY.md deleted file mode 100644 index 9f0c98600..000000000 --- a/docs/DRM_CAPTURE_SECURITY.md +++ /dev/null @@ -1,255 +0,0 @@ -# DRM/KMS capture — security model & threat model - -The optional `drm` feature adds a Linux capture backend that reads the active -scanout directly from DRM/KMS, **bypassing the xdg-desktop-portal consent -dialog**. It exists for unattended / login-screen / Wayland scenarios where the -portal prompt is not acceptable. Because it bypasses consent, treat it as a -**privileged, opt-in host-mode feature**, not a normal Wayland capture backend. - -## How it works - -Reading the active scanout needs `CAP_SYS_ADMIN` (to map other clients' -framebuffers). RustDesk's root `--service` already runs with `CAP_SYS_ADMIN`, so -the `drm` feature does the read **in-process in that root service**: it -`dlopen`s `libdrmtap.so` and calls it in direct mode — no privileged child, no -`setcap` helper. On the **default (split) path** the root service does not touch -pixels: it exports the active scanout as a DMA-BUF and passes just that -**read-only** fd to the unprivileged user `--server` over a dedicated -service-scoped IPC channel (`_drm`) via `SCM_RIGHTS`. The `--server` keeps an -**import-once EGLImage cache** (keyed on the buffer, so a given scanout buffer is -imported once and re-imports are elided), detiles/converts it to linear RGBA in -its own unprivileged address space, and feeds the encoder — so **on that path** -the root service never copies scanout pixels and never loads libEGL/libGLESv2 -(measured on the running service, see *Auditing*). Only the **CPU fallback path** -(used when the seat/driver cannot produce a transferable DMA-BUF, or the consumer -has no render node of its own, see *When the CPU fallback is chosen* below) -copies the scanout to packed BGRA inside the root service and streams those bytes -over `_drm`. - -**The no-GL property is a property of the default path, not of the process.** Be -precise about it, because the CPU fallback is the whole reason the split exists: -converting a scanout in-process means decoding whatever layout it is in, and a -tiled scanout (the common case on modern Intel and AMD) can only be decoded -through the GPU. `drmtap_grab_mapped` therefore reaches libdrmtap's auto-process -step, which lazily `dlopen`s libEGL/libGLESv2 **in the calling process** when the -scanout needs a GPU detile. So a host that has fallen back to the CPU path can -map the GL stack inside the `CAP_SYS_ADMIN` service. What the design does about -that is bound the cases: the fallback is entered only for the three reasons -listed below, never as a silent degradation of the split path (the loader refuses -a `libdrmtap` that cannot export the fd at all, precisely so "old library" cannot -turn into "convert in the privileged process"), and a linear or CPU-mappable -scanout is converted without touching GL. Every host measured here runs the split -path with zero GL regions in the service; a CPU-fallback host is a different -posture and is worth measuring separately. This mirrors the Windows -`portable_service` split (a privileged process captures, an unprivileged one -presents) but reuses RustDesk's own hardened IPC. - -- `libdrmtap.so` is loaded through a small `dlopen` loader (`drmtap_dl`); if the - library or one of its runtime deps is missing the load fails cleanly and the - caller falls back to the PipeWire/portal path. -- The loader also **refuses a library that cannot do the split** — and, more - broadly, any version outside the vetted window. Accepted is exactly the pinned - minor with a patch floor (currently `0.5.x`, `x >= 0`): an older minor is - refused (`0.4.x` included, even though it carries the split entry points, because - it decodes a padded scanout pitch at the wrong stride), and a **newer minor is - refused too** (`0.6.x` onward), because the loader mirrors C struct layouts that are only - field-by-field verified against the pinned minor; widening the window is a - deliberate act done together with re-verifying the layouts and moving the - build pin. Independently of the version report, a library that does not - actually export - `drmtap_grab_desc` / `drmtap_open_render` / `drmtap_convert_dmabuf` (a stale or - pre-release build) is refused as well. The only way to capture with such a library is the - in-process convert, which in the root service means loading the vendor GL stack - there, so it is refused and the caller falls back to PipeWire/portal. The - privileged process therefore never loads GL because of which file happened to - be on the load path; the CPU fallback below is entered only for a fact about - the seat or the consumer. -- The reader restricts the device it opens to a realpath under `/dev/dri/` - (`drm_reader.rs`); RustDesk always runs libdrmtap in direct in-process mode - (`helper_path` is `NULL`). **No `drmtap-helper` binary is built, shipped, or - installed by this package**: there is no `setcap`, no capability-bearing file, - and no capture group in this deployment. Being precise about what that does - and does not guarantee: an empty `helper_path` is not by itself a "helper - disabled" switch in the C. `find_helper` (`privilege_helper.c`) searches six - hardcoded paths, one of which is `/usr/lib/rustdesk/drmtap-helper`, the - directory this package installs into, and `fork`/`exec`s the first executable - it finds if the direct export ever returns `EACCES`/`EPERM`. Here that path is - unreachable for two independent reasons: the root service holds - `CAP_SYS_ADMIN` so the direct export succeeds, and the package builds only the - shared library, so no helper exists at any of those paths. They are all - root-writable-only, so a helper appearing there would not be an escalation - either, but the honest statement is "a privileged child is spawned only if a - helper binary exists at one of those fixed root-owned paths, and this package - never installs one", not "never". -- The `_drm` socket lives beside the hardened `_service` socket - (`/tmp/-service/ipc_drm`). It is `0666` so the unprivileged `--server` - can connect, but every accepted peer is authorized in `handle_drm_conn` - (`authorize_service_scoped_ipc_connection`: peer must be root or the active - session uid, with a `/proc//exe` identity match). Connectable is not - authorized. - -## Threat model - -- **Consent bypass.** This mode does not show the portal "select what to share" - prompt. On a misconfigured install it could expose the login screen, the lock - screen, or another local user's graphical session. -- **The scanout parse runs in the root service.** Moving the read in-process - removes the old `setcap` helper and its world-exec attack surface. On the - **default (split) path** the root service does only a **metadata-only** parse - of the scanout descriptor and exports the DMA-BUF fd; the untrusted-framebuffer - detile / pixel-format conversion runs in the **unprivileged `--server`**, - outside `CAP_SYS_ADMIN`. Export-side validation is therefore metadata-only — - geometry bounded to `<= MAX_DIM` (16384) and `num_planes` in `1..=4` - (`drm_reader.rs` `grab_desc`); there is **no fourcc gate** on the export side, - because the format check is delegated to the unprivileged converter, which - handles every format `libdrmtap` supports (XRGB/ARGB8888, 10-bit XR30/AR30, - HDR, CCS-compressed). The exported fd is **read-only**: `libdrmtap` exports the - DMA-BUF via `drmPrimeHandleToFD` with `DRM_RDWR` dropped (`O_RDONLY`), and - `drm_reader` `dup()`s it — which shares the same open file description and so - preserves that access mode — so the unprivileged consumer can map the scanout - for reading but never write into the live framebuffer. On the **CPU fallback - path** the pixel-format conversion / detile instead runs inside the - `CAP_SYS_ADMIN` service without a seccomp cage; there the frame copy has - format / stride / geometry and integer-overflow guards (`drm_reader.rs` - `grab`), and non-32bpp scanouts are rejected before the copy. The device is - realpath-gated to `/dev/dri/` on both paths. -- **`_drm` is a screen-content channel.** It is authorized per connection (see - above); without that authz any local process could read the screen. Authorization - is also **re-checked on every frame**, not only at accept, because DRM/KMS - capture is not session-scoped: it grabs the physical scanout of a CRTC no matter - which session owns the display. So when the active session changes -- a user - logging in at a greeter -- the greeter's `_drm` stream is CLOSED rather than - continued (`drm: _drm peer no longer matches the active session`; observed with - peer_uid=60578 against active_uid=1000, and the greeter's uinput channel goes - with it). That is what stops an outgoing greeter process from capturing the - logged-in user's screen. The cost is a reconnect, not the session: the client - re-establishes itself against the new session's `--server` on its own in about - 2.5 s (~3.6 s of dark screen, measured 2026-07-31). On the - **default (split) path** the channel carries the scanout DMA-BUF fd, passed to - the unprivileged `--server` over `SCM_RIGHTS` as a **read-only** descriptor - (the `--server` holds an import-once EGLImage cache, so a given scanout buffer - is imported once and re-imports are elided); the peer can map the scanout for - reading but cannot write it. The **CPU fallback path** instead carries plain - packed-BGRA bytes over the same authorized socket (no fd passing, no shared - memory). -- **When the CPU fallback is chosen.** The split path is the default; the - consumer asks the service for the CPU-converted frame in two cases: no render - node can be opened for this seat, or a previous convert on this display - already failed. A third case is a **multi-GPU safety fallback**: if - the service could not name the render node of the GPU that exports the scanout - (an older `libdrmtap` without `drmtap_render_node`) and the host has more than - one render node, the consumer refuses to guess one, because importing a scanout - on a device that did not export it can succeed and return corrupted pixels - rather than fail. The conversion then happens in the service, on the device it - already has open, so it is correct by construction. Hosts with a single render - node have nothing to pick wrong and keep the DMA-BUF fast path. -- **The display wake injects synthetic input from the root service.** It is - compiled in only with the `drm-wake` feature, which `build.py --drm` adds on - top of `drm`, and it can be switched off at runtime with - `enable-drm-display-wake=N`. Building with `--features drm` alone leaves no - wake code in the binary at all, so an operator auditing the deb can answer - "is the injection path even present here?" from the artifact. A - compositor that idles long enough DISABLES a connector, leaving no scanout for - any backend, so on a `_drm` handshake that finds a CONNECTED display with no - CRTC the service emits one synthetic pointer round trip over `/dev/uinput` to - make the compositor re-enable it. The virtual device **declares** two relative - axes and `BTN_LEFT`, because libinput classifies a device before it will treat - its events as pointer activity at all and a single axis with no buttons is - ignored outright (measured three ways on the same idle machine). What it - actually **emits** is `+1` then `-1` on one axis: net-zero displacement, no - button press, no key events. This is deliberate input injection by privileged - code, so its bounds are worth stating precisely: - - it can only be reached through an **already-authorized** `_drm` connection - (same per-connection authz as every other use of the channel), so it grants - nothing to a local attacker that the channel itself does not; - - it runs in the root service because that is the only place it can: - `/dev/uinput` is root-only here, and a modeset of our own is not an option - since the compositor holds DRM master (the sysfs `dpms` attribute is - read-only). Session-bus routes (`org.gnome.ScreenSaver`) authenticate by - uid, refuse root, and are desktop-specific; - - the trigger is narrow — a connected-but-undriven connector, not "no - frames" — and connectors a wake demonstrably cannot bring back are - remembered by connector identity and stop triggering. That memory is - per-connector rather than global, so a permanently dark connector cannot - suppress the wake for a different panel, and it drops any entry later seen - scanning out. Note what that recovery rule does and does not give you: it - clears the moment the display is driven **by anything**, but nothing else - retries, so a connector latched after a wake that failed for a transient - reason stays latched until that display comes back some other way — on an - unattended host, typically not until the service restarts. It is a - deliberate trade against waking on every connection forever for a display - that is never coming; - - it is rate limited to **one wake per 20 s process-wide** with exactly one - concurrent winner (compare-exchange claim), so a reconnect storm cannot - become an input-injection storm. That bounds the injection RATE. It does - not bound how long a screen stays lit, and neither does the one-shot - property below: 20 s is shorter than every idle period measured below, so a - remote peer that reconnects in a loop can have the panel relit after each - idle-off. What that peer gains is a lit panel on a machine whose screen it - is already authorized to watch: it is visible to someone standing there, - not additional access; - - the wake is **one-shot: it resets the compositor's idle timer, it does not - hold the display on**. If nothing else keeps the session awake, the connector - idles off again one full idle period later -- measured 2026-07-31: 30.3 s at - a GDM greeter, 70.3 s in a user session with `idle-delay=60`. Keeping a - screen lit for the length of a session is the job of RustDesk's existing - keep-awake inhibitor, not of this wake, which only recovers a connector that - is *already* dark; - - the uinput device is created and destroyed around the emit — nothing - persists in the input stack between wakes; - - without `/dev/uinput` the wake is skipped and latched off. Such a session - was already view-only (input injection on Wayland needs uinput too), so - this adds no new failure mode. - -## Deployment - -- **Off by default.** The `drm` feature is **not** in the default feature set and - is **not** enabled in standard release packages; the drm-off build is - byte-identical to upstream. Build it explicitly with - `python3 build.py --flutter --drm` (Linux only). -- **Separate opt-in package.** A `--drm` build ships as a distinctly named - `rustdesk-unattended-wayland` package (Conflicts/Replaces/**Provides** `rustdesk` -- - `Provides` is what lets a third-party package that depends on `rustdesk` be satisfied by the - consent-free variant, so it belongs in an audit of this metadata), so - enabling consent-free capture is an explicit install choice. -- **Bundled library, no capabilities.** The package installs the versioned - `libdrmtap.so.0..` plus a `libdrmtap.so.0` soname symlink under - `/usr/lib/rustdesk/`, and the in-process `dlopen` names that absolute path - (`/usr/lib/rustdesk/libdrmtap.so.0`). The package deliberately does **not** - register the directory with the dynamic linker: no - `/etc/ld.so.conf.d/` drop-in and no `ldconfig` trigger are shipped, so a - private library cannot shadow a system one for unrelated binaries - (Debian Policy 10.2). The bare-soname lookups remain only as a fallback for a - development build reached through `LD_LIBRARY_PATH`. - - There is no `setcap`, no `rustdesk-capture` group, and no privileged binary: - the capture runs inside the root `--service`, which already holds the - capability it needs. Hosts without `/dev/dri` access (or where the library - fails to load) transparently fall back to the PipeWire/portal path. -- **Minimum libdrm: 2.4.95.** `libdrmtap` needs the DRM `GetFB2` framebuffer API, which - landed in libdrm 2.4.95. Ubuntu 18.04 is the oldest distribution worth naming here, and it - straddles the floor: base bionic shipped 2.4.91, below it, while the updates/HWE stack - (2.4.101) is above — so read this as "18.04 with updates, or anything newer", not as - "any 18.04". That is an API statement, not a binary-compatibility one: - the `rustdesk-unattended-wayland` deb in this repo's CI is built on an ubuntu-24.04 runner, so the - shipped binaries carry that build host's glibc floor. Running on an older distribution means - building the deb there (or in a matching container), which the libdrm floor above permits. - Capture also requires an active KMS scanout (a Wayland/KMS session with a display - on); on hosts where the compositor drives the display outside DRM/KMS (e.g. the proprietary NVIDIA - X11 stack) there is no capturable CRTC and the path falls back to PipeWire/portal. -- **Recommended for** single-user, physically-controlled, or unattended hosts. - -## Auditing - -```bash -# the bundled capture library and its soname symlink — no capabilities are set on either -ls -l /usr/lib/rustdesk/libdrmtap.so.0* -# the dlopen names the symlink by absolute path, so what matters is where the symlink points: -readlink /usr/lib/rustdesk/libdrmtap.so.0 # expect: the versioned object shipped by the package -# and there should be no other object left beside it (a leftover is not loaded on its own, but it -# is what a stray ldconfig over this directory would repoint the symlink to): -ls /usr/lib/rustdesk/libdrmtap.so.0.* # expect: exactly one versioned object -ls /etc/ld.so.conf.d/ | grep -i rustdesk # expect: no output (none is shipped) -# confirm no privileged helper is present (there should be none) -getcap -r /usr/lib/rustdesk 2>/dev/null # expect: no output -``` diff --git a/libs/scrap/src/common/drm_render.rs b/libs/scrap/src/common/drm_render.rs index f12df71f7..6df6ea61d 100644 --- a/libs/scrap/src/common/drm_render.rs +++ b/libs/scrap/src/common/drm_render.rs @@ -1,7 +1,7 @@ // Unprivileged half of the split DRM/KMS capture path: the root `--service` exports a scanout // dma-buf fd + descriptor, this side imports it and EGL-detiles. libEGL/libGLESv2 are dlopen'd // in the UNPRIVILEGED process on this path; the root service loads them only if it falls back to -// its own CPU-mapped grab (`drmtap_grab_mapped`). See docs/DRM_CAPTURE_SECURITY.md. +// its own CPU-mapped grab (`drmtap_grab_mapped`). use super::drmtap_dl::{self, drmtap_ctx, drmtap_dmabuf_desc, drmtap_frame_info, DrmtapLib}; use super::Pixfmt; diff --git a/libs/scrap/src/common/drmtap_dl.rs b/libs/scrap/src/common/drmtap_dl.rs index 63b46ce8b..0312c75bb 100644 --- a/libs/scrap/src/common/drmtap_dl.rs +++ b/libs/scrap/src/common/drmtap_dl.rs @@ -196,9 +196,20 @@ impl DrmtapLib { std::iter::once(INSTALLED).chain(DEV_ONLY).collect() }; unsafe { - let (lib, name) = candidates - .iter() - .find_map(|n| Library::new(*n).ok().map(|l| (l, *n)))?; + let mut errs = Vec::new(); + let found = candidates.iter().find_map(|n| match Library::new(*n) { + Ok(l) => Some((l, *n)), + Err(e) => { + errs.push(format!("{n}: {e}")); + None + } + }); + let Some((lib, name)) = found else { + // The dlerror names the real cause (a missing soname, a glibc too old for the + // bundled build); the caller only reports that DRM capture is off. + log::warn!("libdrmtap dlopen failed: {}", errs.join("; ")); + return None; + }; // Canonicalize the absolute candidate only: `dlopen` does not search the CWD for a bare // soname, while `canonicalize` resolves a relative name against it. let real = std::path::Path::new(name) From 429c8c67111408bbc04e96a8a38252210fb1b1f2 Mon Sep 17 00:00:00 2001 From: Maison da Silva Date: Thu, 6 Aug 2026 21:31:35 -0300 Subject: [PATCH 06/72] Translate sign-in message to Portuguese (#15770) Translate sign-in message to Portuguese --- src/lang/ptbr.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 8d44d6140..61bf5cf48 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -774,6 +774,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("id_whitelist_caveat_tip", "O ID é informado pelo cliente que se conecta. A lista reduz a exposição e não substitui a senha ou o 2FA"), ("whitelist_cidr_tip", "A notação CIDR é suportada, por exemplo 192.168.1.0/24"), ("Continue", "Continuar"), - ("Browser didn't open? Use the url below to sign in.", ""), + ("Browser didn't open? Use the url below to sign in.", "O navegador não foi aberto? Use a URL abaixo para fazer login."), ].iter().cloned().collect(); } From 6fd96dda6ec73bd03663c1c33493bceceb0e707d Mon Sep 17 00:00:00 2001 From: Panos Date: Fri, 7 Aug 2026 09:39:44 +0300 Subject: [PATCH 07/72] Update Greek translations for various terms (#15782) --- src/lang/el.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lang/el.rs b/src/lang/el.rs index 5ba349a9c..deca79aa6 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -333,7 +333,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Secure Connection", "Ασφαλής σύνδεση"), ("Insecure Connection", "Μη ασφαλής σύνδεση"), ("Scale original", "Κλιμάκωση πρωτότυπου"), - ("Scale adaptive", "Προσαρμοσμένη κλίμακα"), + ("Scale adaptive", "Αυτόματη προσαρμογή κλίμακας"), ("General", "Γενικά"), ("Security", "Ασφάλεια"), ("Theme", "Θέμα"), @@ -709,9 +709,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Supported only in the installed version.", "Υποστηρίζεται μόνο στην εγκατεστημένη έκδοση."), ("elevation_username_tip", "Εισαγάγετε όνομα χρήστη ή τομέα\\όνομα χρήστη"), ("Preparing for installation ...", "Προετοιμασία για εγκατάσταση..."), - ("Show my cursor", "Εμφάνιση του κέρσορα μου"), - ("Scale custom", "Προσαρμοσμένη κλίμακα"), - ("Custom scale slider", "Ρυθμιστικό προσαρμοσμένης κλίμακας"), + ("Show my cursor", "Εμφάνιση του δρομέα μου"), + ("Scale custom", "Κλίμακα χρήστη"), + ("Custom scale slider", "Γραμμή ρύθμισης κλίμακας χρήστη"), ("Decrease", "Μείωση"), ("Increase", "Αύξηση"), ("Show virtual mouse", "Εμφάνιση εικονικού ποντικιού"), From 4234b99029bf32c23098b4eaeec8efc135c8e80a Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:20:57 +0800 Subject: [PATCH 08/72] WebClient: 3.44 webcodecs offline (#15722) * feat(web): zero-readback WebCodecs video path Decoded VideoFrames from js/src/webcodecs.js are handed to Flutter via window.onVideoFrame and imported GPU-side with createImageFromTextureSource; any failure unregisters the hook so the JS side falls back to RGBA readback. Co-Authored-By: Claude Fable 5 * fix(web): load bundled terminal font when Google CDNs are unreachable In air-gapped deployments GoogleFonts.robotoMono() cannot download the terminal font; when index.html signals offline mode, load the copy bundled with the web app under the family name google_fonts registers. Part of the fix for rustdesk/rustdesk-server-pro#996. Co-Authored-By: Claude Fable 5 * ci: bump windows arm64 to Flutter 3.44.8, add web build patch script apply_flutter_3.44_web_patches.sh prepares a 3.44.x web build on top of the shared source patches: qr_code_scanner's web impl needs dart:ui_web for the removed platformViewRegistry, and flutter/web/fonts is refreshed to the font paths the 3.44 engine requests. The disabled build-rustdesk-web job runs it automatically once FLUTTER_VERSION moves to 3.44.x, and version-guarded 'Patch flutter' steps no longer fail when the guard does not match. Co-Authored-By: Claude Fable 5 * fix(web): prevent stale WebCodecs frames across sessions Signed-off-by: fufesou * fix(web): harden WebCodecs reconnect and Flutter 3.44 patches Signed-off-by: fufesou * fix(ci): harden Flutter 3.44 patch input validation Validate required files before checking patch state, parameterize the theme-range validator, and prevent missing inputs from satisfying NO_MATCHES checks. Signed-off-by: fufesou * Remove unused code Signed-off-by: fufesou * fix(web): retry font loading and dispose stale decoded images Signed-off-by: fufesou * remove unused code Signed-off-by: fufesou * fix(web): Bad state: RenderBox was not laid out Signed-off-by: fufesou --------- Signed-off-by: fufesou Co-authored-by: Claude Fable 5 Co-authored-by: fufesou --- .../apply_flutter_3.44_source_patches.sh | 115 ++++++++++++++- .../patches/apply_flutter_3.44_web_patches.sh | 51 +++++++ .github/workflows/bridge.yml | 2 +- .github/workflows/flutter-build.yml | 35 ++++- flutter/lib/main.dart | 3 +- flutter/lib/mobile/pages/terminal_page.dart | 6 + flutter/lib/models/model.dart | 37 ++++- flutter/lib/models/native_model.dart | 7 + flutter/lib/models/web_model.dart | 67 +++++++++ flutter/lib/models/web_video_frame_queue.dart | 133 ++++++++++++++++++ flutter/lib/web/dummy.dart | 2 + flutter/lib/web/terminal_font.dart | 33 +++++ 12 files changed, 472 insertions(+), 19 deletions(-) create mode 100755 .github/patches/apply_flutter_3.44_web_patches.sh create mode 100644 flutter/lib/models/web_video_frame_queue.dart create mode 100644 flutter/lib/web/terminal_font.dart diff --git a/.github/patches/apply_flutter_3.44_source_patches.sh b/.github/patches/apply_flutter_3.44_source_patches.sh index 3a7ab99dc..2b4bfcc0d 100644 --- a/.github/patches/apply_flutter_3.44_source_patches.sh +++ b/.github/patches/apply_flutter_3.44_source_patches.sh @@ -17,6 +17,109 @@ # therefore CRLF-safe. set -euo pipefail +readonly NO_MATCHES=0 +readonly SINGLE_MATCH=1 +readonly THEME_MATCHES=2 + +has_exact_count() { + local -r expected_count="$1" + local -r pattern="$2" + local -r file="$3" + local actual_count + [[ -r "$file" ]] || return 1 + actual_count="$(grep -cF "$pattern" "$file" || true)" + [[ "$actual_count" -eq "$expected_count" ]] +} + +# The target background-color line must directly follow DialogThemeData in the selected range. +has_dialog_background_in_theme_range() { + local -r start_pattern="$1" + local -r end_pattern="$2" + local -r target_pattern="$3" + local -r file="$4" + awk -v start_pattern="$start_pattern" \ + -v end_pattern="$end_pattern" \ + -v target_pattern="$target_pattern" ' + index($0, start_pattern) { + in_theme = 1 + next + } + in_theme && index($0, end_pattern) { + exit + } + in_theme && index($0, "dialogTheme: DialogThemeData(") { + if (getline > 0) { + line = $0 + sub(/\r$/, "", line) + sub(/^[[:space:]]+/, "", line) + matched = line == target_pattern + } + exit + } + END { + exit matched ? 0 : 1 + } + ' "$file" +} + +validate_patch_inputs() { + if [[ ! -f flutter/lib/common.dart || ! -r flutter/lib/common.dart ]]; then + echo "Flutter 3.44 source patch input is missing or unreadable: flutter/lib/common.dart" >&2 + return 1 + fi + if [[ ! -f flutter/pubspec.yaml || ! -r flutter/pubspec.yaml ]]; then + echo "Flutter 3.44 source patch input is missing or unreadable: flutter/pubspec.yaml" >&2 + return 1 + fi +} + +is_complete_patch_state() { + has_exact_count "$THEME_MATCHES" 'dialogTheme: DialogThemeData(' flutter/lib/common.dart && + has_exact_count "$THEME_MATCHES" 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart && + has_exact_count "$SINGLE_MATCH" 'backgroundColor: Colors.white,' flutter/lib/common.dart && + has_exact_count "$SINGLE_MATCH" 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart && + has_exact_count "$SINGLE_MATCH" 'extended_text: 15.0.2' flutter/pubspec.yaml && + has_exact_count "$SINGLE_MATCH" 'google_fonts: ^8.1.0' flutter/pubspec.yaml && + has_exact_count "$NO_MATCHES" 'dialogTheme: DialogTheme(' flutter/lib/common.dart && + has_exact_count "$NO_MATCHES" 'tabBarTheme: const TabBarTheme(' flutter/lib/common.dart && + has_exact_count "$NO_MATCHES" 'extended_text: 14.0.0' flutter/pubspec.yaml && + has_exact_count "$NO_MATCHES" 'google_fonts: ^6.2.1' flutter/pubspec.yaml && + has_dialog_background_in_theme_range 'static ThemeData lightTheme = ThemeData(' \ + 'static ThemeData darkTheme = ThemeData(' 'backgroundColor: Colors.white,' \ + flutter/lib/common.dart && + has_dialog_background_in_theme_range 'static ThemeData darkTheme = ThemeData(' \ + 'scrollbarTheme: scrollbarThemeDark,' 'backgroundColor: Color(0xFF18191E),' \ + flutter/lib/common.dart +} + +is_unpatched_state() { + has_exact_count "$THEME_MATCHES" 'dialogTheme: DialogTheme(' flutter/lib/common.dart && + has_exact_count "$THEME_MATCHES" 'tabBarTheme: const TabBarTheme(' flutter/lib/common.dart && + has_exact_count "$SINGLE_MATCH" 'extended_text: 14.0.0' flutter/pubspec.yaml && + has_exact_count "$SINGLE_MATCH" 'google_fonts: ^6.2.1' flutter/pubspec.yaml && + has_exact_count "$NO_MATCHES" 'dialogTheme: DialogThemeData(' flutter/lib/common.dart && + has_exact_count "$NO_MATCHES" 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart && + has_exact_count "$NO_MATCHES" 'backgroundColor: Colors.white,' flutter/lib/common.dart && + has_exact_count "$NO_MATCHES" 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart && + has_exact_count "$NO_MATCHES" 'extended_text: 15.0.2' flutter/pubspec.yaml && + has_exact_count "$NO_MATCHES" 'google_fonts: ^8.1.0' flutter/pubspec.yaml +} + +if ! validate_patch_inputs; then + exit 1 +fi + +if is_complete_patch_state; then + echo "Flutter 3.44 source patches already applied." + git --no-pager diff -- flutter/lib/common.dart flutter/pubspec.yaml + exit 0 +fi + +if ! is_unpatched_state; then + echo "Flutter 3.44 source patches are partially applied or their anchors have drifted." >&2 + exit 1 +fi + # ThemeData API renames (Flutter 3.27+): sed -i 's/dialogTheme: DialogTheme(/dialogTheme: DialogThemeData(/g' flutter/lib/common.dart sed -i 's/tabBarTheme: const TabBarTheme(/tabBarTheme: const TabBarThemeData(/g' flutter/lib/common.dart @@ -28,12 +131,10 @@ sed -i '/static ThemeData darkTheme = ThemeData(/,/scrollbarTheme: scrollbarThem sed -i 's/extended_text: 14.0.0/extended_text: 15.0.2/' flutter/pubspec.yaml sed -i 's/google_fonts: \^6.2.1/google_fonts: ^8.1.0/' flutter/pubspec.yaml -# Fail loudly if any expected string drifted, so we never silently build unpatched: -grep -qF 'dialogTheme: DialogThemeData(' flutter/lib/common.dart -grep -qF 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart -grep -qF 'backgroundColor: Colors.white,' flutter/lib/common.dart -grep -qF 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart -grep -qF 'extended_text: 15.0.2' flutter/pubspec.yaml -grep -qF 'google_fonts: ^8.1.0' flutter/pubspec.yaml +# Fail loudly if any expected substitution did not produce the complete state. +if ! is_complete_patch_state; then + echo "Flutter 3.44 source patches did not produce the expected state." >&2 + exit 1 +fi git --no-pager diff -- flutter/lib/common.dart flutter/pubspec.yaml diff --git a/.github/patches/apply_flutter_3.44_web_patches.sh b/.github/patches/apply_flutter_3.44_web_patches.sh new file mode 100755 index 000000000..24ce7f1b4 --- /dev/null +++ b/.github/patches/apply_flutter_3.44_web_patches.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Prepares a web build on Flutter 3.44.x. Companion to +# apply_flutter_3.44_source_patches.sh (which it runs first): the web target +# additionally needs qr_code_scanner's web implementation patched for the +# dart:ui platformViewRegistry removal, and flutter/web/fonts refreshed with +# the font paths the 3.44 engine requests for offline/air-gapped support +# (rustdesk-server-pro#996; see flutter/web/fonts/sync_fonts.py). +# +# Run from the repository root with Flutter 3.44.x on PATH, then build: +# bash .github/patches/apply_flutter_3.44_web_patches.sh +# (cd flutter && flutter build web --release) # or ./web/js/flutter_build.py +# +# Idempotent. To undo the source changes locally: +# git checkout -- flutter/lib/common.dart flutter/pubspec.yaml flutter/pubspec.lock +set -euo pipefail + +flutter --version | grep -q "Flutter 3\.44\." || { + echo "Flutter 3.44.x must be on PATH; found:" >&2 + flutter --version | grep "^Flutter" >&2 || true + exit 1 +} + +# Shared 3.44 source/pubspec patches own their complete-state validation. +bash .github/patches/apply_flutter_3.44_source_patches.sh + +# Populate the pub cache with the 3.44 dependency resolution. +(cd flutter && flutter pub get) + +# qr_code_scanner 1.0.1 (unmaintained) reads platformViewRegistry from +# dart:ui, which Flutter 3.44 removed; point it at dart:ui_web instead. The +# patched file also compiles on Flutter 3.24 (dart:ui_web exists there), so +# mutating the shared pub cache is safe for other local builds. +QR_WEB="${PUB_CACHE:-$HOME/.pub-cache}/hosted/pub.dev/qr_code_scanner-1.0.1/lib/src/web/flutter_qr_web.dart" +if ! grep -qF "dart:ui_web" "$QR_WEB"; then + sed -i.bak "s|import 'dart:ui' as ui;|import 'dart:ui' as ui; import 'dart:ui_web' as ui_web;|" "$QR_WEB" + rm -f "$QR_WEB.bak" +fi +if grep -qF "ui.platformViewRegistry" "$QR_WEB"; then + sed -i.bak "s|ui\.platformViewRegistry|ui_web.platformViewRegistry|g" "$QR_WEB" + rm -f "$QR_WEB.bak" +fi + +# Mirror the fonts this engine version requests into flutter/web/fonts. +python3 flutter/web/fonts/sync_fonts.py + +# Fail loudly if any expected state is missing: +grep -qF "import 'dart:ui' as ui; import 'dart:ui_web' as ui_web;" "$QR_WEB" +grep -qF "ui_web.platformViewRegistry" "$QR_WEB" +grep -qF 'google_fonts: ^8.1.0' flutter/pubspec.yaml + +echo "Flutter 3.44 web patches applied." diff --git a/.github/workflows/bridge.yml b/.github/workflows/bridge.yml index a7b74fa55..9d31399c8 100644 --- a/.github/workflows/bridge.yml +++ b/.github/workflows/bridge.yml @@ -30,7 +30,7 @@ jobs: target: x86_64-unknown-linux-gnu, os: ubuntu-22.04, extra-build-args: "", - flutter-version: "3.44.0", + flutter-version: "3.44.8", artifact-name: "bridge-artifact-flutter-3.44", } steps: diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 95cfdd8e3..4f1dbb1c9 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -31,7 +31,7 @@ env: # engine is 3.44. Every other platform stays on FLUTTER_VERSION (3.24.5) until Windows 7 # support is restored after the upstream-wide Flutter bump. The arm64 job patches the few # 3.44-only source/pubspec changes on the fly (see "Patch RustDesk sources for Flutter 3.44"). - FLUTTER_WINDOWS_ARM_VERSION: "3.44.0" + FLUTTER_WINDOWS_ARM_VERSION: "3.44.8" # for arm64 linux because official Dart SDK does not work FLUTTER_ELINUX_VERSION: "3.16.9" TAG_NAME: "${{ inputs.upload-tag }}" @@ -224,7 +224,9 @@ jobs: run: | cp .github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff $(dirname $(dirname $(which flutter))) cd $(dirname $(dirname $(which flutter))) - [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply flutter_3.24.4_dropdown_menu_enableFilter.diff + if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then + git apply flutter_3.24.4_dropdown_menu_enableFilter.diff + fi - name: Patch RustDesk sources for Flutter 3.44 (arm64) # arm64 is the only target on Flutter 3.44; apply its source/pubspec deltas on the fly @@ -595,7 +597,9 @@ jobs: - name: Patch flutter run: | cd $(dirname $(dirname $(which flutter))) - [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then + git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + fi - name: Setup vcpkg with Github Actions binary cache uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 @@ -774,7 +778,9 @@ jobs: - name: Patch flutter run: | cd $(dirname $(dirname $(which flutter))) - [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then + git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + fi - name: Workaround for flutter issue shell: bash @@ -1033,7 +1039,9 @@ jobs: - name: Patch flutter run: | cd $(dirname $(dirname $(which flutter))) - [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + if [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]]; then + git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + fi - uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1 id: setup-ndk @@ -1305,7 +1313,9 @@ jobs: - name: Patch flutter run: | cd $(dirname $(dirname $(which flutter))) - [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + if [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]]; then + git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + fi - name: Restore bridge files uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -2413,7 +2423,18 @@ jobs: shell: bash run: | cd $(dirname $(dirname $(which flutter))) - [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then + git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + fi + + - name: Patch sources for Flutter 3.44 web + # No-op while the web stays on Flutter 3.24.5; makes this job work as-is + # once FLUTTER_VERSION moves to 3.44.x (qr_code_scanner + fonts, see script). + shell: bash + run: | + if [[ "${{ env.FLUTTER_VERSION }}" == 3.44.* ]]; then + bash .github/patches/apply_flutter_3.44_web_patches.sh + fi # https://rustdesk.com/docs/en/dev/build/web/ - name: Build web diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 9bd68ed60..7e0a8cb2b 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -588,7 +588,8 @@ _registerEventHandler() { Widget keyListenerBuilder(BuildContext context, Widget? child) { return RawKeyboardListener( - focusNode: FocusNode(), + // `skipTraversal: isWeb` is to fix "Bad state: RenderBox was not laid out: minified:aeL#c19e4" + focusNode: FocusNode(skipTraversal: isWeb), child: child ?? Container(), onKey: (RawKeyEvent event) { if (event.logicalKey == LogicalKeyboardKey.shiftLeft) { diff --git a/flutter/lib/mobile/pages/terminal_page.dart b/flutter/lib/mobile/pages/terminal_page.dart index a4a76f9af..800b0f8f4 100644 --- a/flutter/lib/mobile/pages/terminal_page.dart +++ b/flutter/lib/mobile/pages/terminal_page.dart @@ -10,6 +10,8 @@ import 'package:flutter_hbb/models/model.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/terminal_model.dart'; import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart'; +import 'package:flutter_hbb/web/dummy.dart' + if (dart.library.html) 'package:flutter_hbb/web/terminal_font.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:xterm/xterm.dart'; import '../../desktop/pages/terminal_connection_manager.dart'; @@ -67,6 +69,10 @@ class _TerminalPageState extends State super.initState(); WidgetsBinding.instance.addObserver(this); + if (isWeb) { + loadLocalTerminalFontIfNeeded(); + } + debugPrint( '[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}'); diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 175e3ff2d..4a6088bd3 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -1952,6 +1952,12 @@ class ImageModel with ChangeNotifier { platformFFI.nextRgba(sessionId, display); } + // web only: image already created from a decoded WebCodecs frame + Future onImage( + int display, ui.Image image, bool Function() isCurrentSession) async { + await update(image, isCurrentSession: isCurrentSession); + } + decodeAndUpdate(int display, Uint8List rgba) async { final pid = parent.target?.id; final rect = parent.target?.ffiModel.pi.getDisplayRect(display); @@ -1963,11 +1969,16 @@ class ImageModel with ChangeNotifier { ? ui.PixelFormat.rgba8888 : ui.PixelFormat.bgra8888, ); - if (parent.target?.id != pid) return; + if (parent.target?.id != pid) { + image?.dispose(); + return; + } await update(image); } - update(ui.Image? image) async { + Future update(ui.Image? image, + {bool Function()? isCurrentSession}) async { + if (_disposeIfStale(image, isCurrentSession)) return; if (_image == null && image != null) { if (isDesktop || isWebDesktop) { await parent.target?.canvasModel.updateViewStyle(); @@ -1978,11 +1989,19 @@ class ImageModel with ChangeNotifier { await initializeCursorAndCanvas(parent.target!); } } + if (_disposeIfStale(image, isCurrentSession)) return; _image?.dispose(); _image = image; if (image != null) notifyListeners(); } + bool _disposeIfStale(ui.Image? image, bool Function()? isCurrentSession) { + if (image == null || isCurrentSession == null) return false; + if (isCurrentSession()) return false; + image.dispose(); + return true; + } + // mobile only double get maxScale { if (_image == null) return 1.5; @@ -3853,6 +3872,15 @@ class FFI { onEvent2UIRgba(); imageModel.onRgba(display, data); }); + platformFFI.setVideoFrameCallback((int display, ui.Image image, + bool Function() isCurrentSession) async { + if (!isCurrentSession()) { + image.dispose(); + return; + } + await onEvent2UIRgba(); + await imageModel.onImage(display, image, isCurrentSession); + }); this.id = id; return; } @@ -3940,7 +3968,7 @@ class FFI { this.id = id; } - void onEvent2UIRgba() async { + Future onEvent2UIRgba() async { if (ffiModel.waitForImageDialogShow.isTrue) { ffiModel.waitForImageDialogShow.value = false; ffiModel.waitForImageTimer?.cancel(); @@ -3996,6 +4024,9 @@ class FFI { /// Close the remote session. Future close({bool closeSession = true}) async { closed = true; + if (isWeb) { + platformFFI.clearVideoFrameCallback(); + } chatModel.close(); // Close all terminal models for (final model in _terminalModels.values) { diff --git a/flutter/lib/models/native_model.dart b/flutter/lib/models/native_model.dart index e73cbc0cb..8c3c5cf71 100644 --- a/flutter/lib/models/native_model.dart +++ b/flutter/lib/models/native_model.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:ffi'; import 'dart:io'; +import 'dart:ui' as ui; import 'package:device_info_plus/device_info_plus.dart'; import 'package:external_path/external_path.dart'; @@ -283,6 +284,12 @@ class PlatformFFI { void setRgbaCallback(void Function(int, Uint8List) fun) async {} + // web only, decoded WebCodecs frames arriving as ready-made images + void setVideoFrameCallback( + Future Function(int, ui.Image, bool Function()) fun) {} + + void clearVideoFrameCallback() {} + void startDesktopWebListener() {} void stopDesktopWebListener() {} diff --git a/flutter/lib/models/web_model.dart b/flutter/lib/models/web_model.dart index 5241c3974..b65825e51 100644 --- a/flutter/lib/models/web_model.dart +++ b/flutter/lib/models/web_model.dart @@ -2,14 +2,18 @@ import 'dart:convert'; import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; import 'dart:typed_data'; import 'dart:js'; import 'dart:html'; import 'dart:async'; +import 'dart:ui' as ui; +import 'dart:ui_web' as ui_web; import 'package:flutter/foundation.dart'; import 'package:flutter_hbb/common/widgets/login.dart'; import 'package:flutter_hbb/models/state_model.dart'; +import 'package:flutter_hbb/models/web_video_frame_queue.dart'; import 'package:flutter_hbb/web/bridge.dart'; import 'package:flutter_hbb/common.dart'; @@ -18,6 +22,22 @@ import 'package:uuid/uuid.dart'; final List> mouseListeners = []; final List> keyListeners = []; +// WebCodecs VideoFrames handed over from js/src/webcodecs.js arrive as plain +// interop objects (the package language version predates extension types). +// This side owns each frame and must close it quickly: hardware decoders +// stall once their small output frame pool is exhausted. +int _videoFrameWidth(JSObject frame) => + frame.getProperty('displayWidth'.toJS).toDartInt; +int _videoFrameHeight(JSObject frame) => + frame.getProperty('displayHeight'.toJS).toDartInt; +void _closeVideoFrame(JSObject frame) { + try { + frame.callMethod('close'.toJS); + } catch (error) { + debugPrint('VideoFrame.close failed: $error'); + } +} + typedef HandleEvent = Future Function(Map evt); class PlatformFFI { @@ -33,6 +53,13 @@ class PlatformFFI { } PlatformFFI._() { + _videoFrameQueue = WebVideoFrameQueue( + importFrame: _importVideoFrame, + closeFrame: _closeVideoFrame, + disposeImage: (image) => image.dispose(), + onImportError: _handleVideoFrameImportError, + onCallbackError: _handleVideoImageCallbackError, + ); window.document.addEventListener( 'visibilitychange', (event) => { @@ -162,6 +189,46 @@ class PlatformFFI { }; } + late final WebVideoFrameQueue _videoFrameQueue; + + // Zero-readback video path: the JS decoder hands decoded VideoFrames here + // (checking typeof window.onVideoFrame before every frame), and the engine + // imports them GPU-to-GPU via createImageBitmap. Unregistering the JS global + // reverts the JS side to the RGBA readback path. + void setVideoFrameCallback( + Future Function(int, ui.Image, bool Function()) fun) { + _videoFrameQueue.beginSession(fun); + if (!_videoFrameQueue.isEnabled) return; + globalContext.setProperty( + 'onVideoFrame'.toJS, + ((JSNumber display, JSObject frame) { + _videoFrameQueue.submit(display.toDartInt, frame); + }).toJS, + ); + } + + void clearVideoFrameCallback() { + _videoFrameQueue.endSession(); + globalContext.setProperty('onVideoFrame'.toJS, null); + } + + Future _importVideoFrame(JSObject frame) async { + return await ui_web.createImageFromTextureSource(frame, + width: _videoFrameWidth(frame), height: _videoFrameHeight(frame)); + } + + void _handleVideoFrameImportError(Object error, StackTrace stackTrace) { + debugPrintStack( + label: 'createImageFromTextureSource failed, using RGBA path: $error', + stackTrace: stackTrace); + globalContext.setProperty('onVideoFrame'.toJS, null); + } + + void _handleVideoImageCallbackError(Object error, StackTrace stackTrace) { + debugPrintStack( + label: 'video image callback error: $error', stackTrace: stackTrace); + } + void startDesktopWebListener() { mouseListeners.add( window.document.onContextMenu.listen((evt) => evt.preventDefault())); diff --git a/flutter/lib/models/web_video_frame_queue.dart b/flutter/lib/models/web_video_frame_queue.dart new file mode 100644 index 000000000..b78b69166 --- /dev/null +++ b/flutter/lib/models/web_video_frame_queue.dart @@ -0,0 +1,133 @@ +import 'dart:async'; + +typedef VideoFrameImporter = Future Function(Frame frame); +typedef VideoFrameCloser = void Function(Frame frame); +typedef VideoImageDisposer = void Function(Image image); +typedef VideoSessionValidator = bool Function(); +typedef VideoImageCallback = Future Function( + int display, Image image, VideoSessionValidator isCurrentSession); +typedef VideoQueueErrorCallback = void Function( + Object error, StackTrace stackTrace); + +class WebVideoFrameQueue { + WebVideoFrameQueue({ + required VideoFrameImporter importFrame, + required VideoFrameCloser closeFrame, + required VideoImageDisposer disposeImage, + required VideoQueueErrorCallback onImportError, + required VideoQueueErrorCallback onCallbackError, + }) : _importFrame = importFrame, + _closeFrame = closeFrame, + _disposeImage = disposeImage, + _onImportError = onImportError, + _onCallbackError = onCallbackError; + + final VideoFrameImporter _importFrame; + final VideoFrameCloser _closeFrame; + final VideoImageDisposer _disposeImage; + final VideoQueueErrorCallback _onImportError; + final VideoQueueErrorCallback _onCallbackError; + final Map> _pending = {}; + + VideoImageCallback? _callback; + int _generation = 0; + bool _processing = false; + bool _enabled = true; + + bool get isEnabled => _enabled; + + void beginSession(VideoImageCallback callback) { + _invalidateSession(); + _enabled = true; + _callback = callback; + } + + void endSession() { + _invalidateSession(); + _callback = null; + } + + void _invalidateSession() { + _generation++; + for (final queued in _pending.values) { + _closeFrame(queued.frame); + } + _pending.clear(); + } + + bool submit(int display, Frame frame) { + if (!_enabled || _callback == null) { + _closeFrame(frame); + return false; + } + final replaced = _pending.remove(display); + if (replaced != null) { + _closeFrame(replaced.frame); + } + _pending[display] = _QueuedFrame(display, frame, _generation); + _startProcessing(); + return true; + } + + void _startProcessing() { + if (_processing) return; + _processing = true; + unawaited(Future(_process)); + } + + Future _process() async { + while (_pending.isNotEmpty) { + final display = _pending.keys.first; + final queued = _pending.remove(display)!; + if (!_enabled || queued.generation != _generation) { + _closeFrame(queued.frame); + continue; + } + await _importAndDeliver(queued); + } + _processing = false; + } + + Future _importAndDeliver(_QueuedFrame queued) async { + Image? image; + try { + image = await _importFrame(queued.frame); + } catch (error, stackTrace) { + if (queued.generation == _generation) { + _enabled = false; + _onImportError(error, stackTrace); + } + } finally { + _closeFrame(queued.frame); + } + if (image != null) { + await _deliver(queued, image); + } + } + + Future _deliver(_QueuedFrame queued, Image image) async { + final callback = _callback; + bool isCurrentSession() => + _enabled && + queued.generation == _generation && + identical(callback, _callback); + if (!isCurrentSession() || callback == null) { + _disposeImage(image); + return; + } + try { + await callback(queued.display, image, isCurrentSession); + } catch (error, stackTrace) { + _disposeImage(image); + _onCallbackError(error, stackTrace); + } + } +} + +class _QueuedFrame { + const _QueuedFrame(this.display, this.frame, this.generation); + + final int display; + final Frame frame; + final int generation; +} diff --git a/flutter/lib/web/dummy.dart b/flutter/lib/web/dummy.dart index b9e3b80b6..0063c38bd 100644 --- a/flutter/lib/web/dummy.dart +++ b/flutter/lib/web/dummy.dart @@ -12,3 +12,5 @@ Future webSendLocalFiles( required bool isRemote}) { throw UnimplementedError("webSendLocalFiles"); } + +Future loadLocalTerminalFontIfNeeded() async {} diff --git a/flutter/lib/web/terminal_font.dart b/flutter/lib/web/terminal_font.dart new file mode 100644 index 000000000..964924e4c --- /dev/null +++ b/flutter/lib/web/terminal_font.dart @@ -0,0 +1,33 @@ +import 'dart:html' as html; +import 'dart:js' as js; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +bool _loadRequested = false; + +/// When Google CDNs are unreachable, `index.html` sets +/// `window.rustdeskLocalFonts` and `GoogleFonts.robotoMono()` cannot download +/// the terminal font. Load the copy bundled with the web app instead, +/// registered under the family name google_fonts gives the terminal's +/// TextStyle ('RobotoMono_regular'). +Future loadLocalTerminalFontIfNeeded() async { + if (_loadRequested || js.context['rustdeskLocalFonts'] != true) { + return; + } + _loadRequested = true; + try { + final req = await html.HttpRequest.request( + 'fonts/RobotoMono-Regular.ttf', + responseType: 'arraybuffer', + ); + final data = ByteData.view(req.response as ByteBuffer); + final loader = FontLoader('RobotoMono_regular') + ..addFont(Future.value(data)); + await loader.load(); + } catch (e) { + _loadRequested = false; + debugPrint('Failed to load bundled Roboto Mono: $e'); + } +} From d057fe14b2fb24d7a4cd6d64fb9f1c6d72dbea3f Mon Sep 17 00:00:00 2001 From: lunar-me Date: Sat, 8 Aug 2026 13:33:25 +1200 Subject: [PATCH 09/72] docs: fix singular contribution in docs/CONTRIBUTING.md (#15789) Co-authored-by: pi --- docs/CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 31fd632e6..43bbf27a6 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to RustDesk -RustDesk welcomes contribution from everyone. Here are the guidelines if you are +RustDesk welcomes contributions from everyone. Here are the guidelines if you are thinking of helping us: ## Contributions From 11190fa54e45fd244ad46b46052f92be6a01d3c5 Mon Sep 17 00:00:00 2001 From: lunar-me Date: Sat, 8 Aug 2026 13:33:58 +1200 Subject: [PATCH 10/72] docs: fix comma splice gui tutorial in README.md (#15787) Co-authored-by: pi --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ae5c8d37c..b30a34cb2 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ RustDesk welcomes contribution from everyone. See [CONTRIBUTING.md](docs/CONTRIB ## Dependencies -Desktop versions use Flutter or Sciter (deprecated) for GUI, this tutorial is for Sciter only, since it is easier and more friendly to start. Check out our [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for building Flutter version. +Desktop versions use Flutter or Sciter (deprecated) for GUI. This tutorial is for Sciter only, since it is easier and more friendly to start. Check out our [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for building the Flutter version. Please download Sciter dynamic library yourself. From 291507664278f0df273c31f58b9318ff44b28a18 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Sun, 9 Aug 2026 07:04:54 -0300 Subject: [PATCH 11/72] fix(linux): bound the xrandr call in the wayland primary-display lookup (#15802) `try_xrandr_primary` runs a bare `Command::new("xrandr").output()`. Its two siblings in the same file, `try_kscreen_primary` and the gdbus one, both go through `run_with_timeout(.., COMMAND_TIMEOUT)`, and the comment above that helper says why: these commands are known to hang. xrandr is the one left bare. It matters because of where it runs. `get_primary_monitor` is called from `get_displays` with the process-wide `DISPLAYS` guard held, and on a Wayland host the caller can be the service, which has no DISPLAY and no session bus. An X client that blocks there blocks every consumer of the display list behind the same lock. No behaviour change when xrandr answers: same command, same parsing, one second of patience. --- libs/scrap/src/wayland/display.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/scrap/src/wayland/display.rs b/libs/scrap/src/wayland/display.rs index bed90fd76..e3c5ebede 100644 --- a/libs/scrap/src/wayland/display.rs +++ b/libs/scrap/src/wayland/display.rs @@ -76,7 +76,9 @@ fn run_with_timeout( // 2. The distro may not have xrandr installed by default. // 3. xrandr may not report "primary" in its output. eg. openSUSE Leap 15.6 KDE Plasma. fn try_xrandr_primary() -> Option { - let output = Command::new("xrandr").output().ok()?; + // Bounded like its two siblings below: this runs inside the held `DISPLAYS` guard, and from a + // service with no DISPLAY and no session bus, where an X client can block indefinitely. + let output = run_with_timeout("xrandr", &[], COMMAND_TIMEOUT, "xrandr")?; if !output.status.success() { return None; } From 7c23fd307349c2d436dbcadc9cff9d4099187a42 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:40:50 +0800 Subject: [PATCH 12/72] =?UTF-8?q?Revert=20"fix(linux):=20bound=20the=20xra?= =?UTF-8?q?ndr=20call=20in=20the=20wayland=20primary-display=20look?= =?UTF-8?q?=E2=80=A6"=20(#15806)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 291507664278f0df273c31f58b9318ff44b28a18. --- libs/scrap/src/wayland/display.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/libs/scrap/src/wayland/display.rs b/libs/scrap/src/wayland/display.rs index e3c5ebede..bed90fd76 100644 --- a/libs/scrap/src/wayland/display.rs +++ b/libs/scrap/src/wayland/display.rs @@ -76,9 +76,7 @@ fn run_with_timeout( // 2. The distro may not have xrandr installed by default. // 3. xrandr may not report "primary" in its output. eg. openSUSE Leap 15.6 KDE Plasma. fn try_xrandr_primary() -> Option { - // Bounded like its two siblings below: this runs inside the held `DISPLAYS` guard, and from a - // service with no DISPLAY and no session bus, where an X client can block indefinitely. - let output = run_with_timeout("xrandr", &[], COMMAND_TIMEOUT, "xrandr")?; + let output = Command::new("xrandr").output().ok()?; if !output.status.success() { return None; } From 594e63805c2fa2e6b214c07dd7869dba52f4d627 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Mon, 10 Aug 2026 16:05:13 +0800 Subject: [PATCH 13/72] harden login request retry --- src/server/connection.rs | 144 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/src/server/connection.rs b/src/server/connection.rs index 25d9b6792..90729fb8d 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -363,6 +363,9 @@ pub struct Connection { server_audit_file: String, controlled_context: Option, lr: LoginRequest, + // Authentication retries may update credentials, but not the requested session scope. + // A digest, so no peer-controlled strings are retained. + login_scope: Option<[u8; 32]>, peer_argb: u32, session_last_recv_time: Option>>, chat_unanswered: bool, @@ -560,6 +563,7 @@ impl Connection { server_audit_file: "".to_owned(), controlled_context, lr: Default::default(), + login_scope: None, peer_argb: 0u32, session_last_recv_time: None, chat_unanswered: false, @@ -2621,6 +2625,90 @@ impl Connection { self.terminal_persistent = false; } + // Approval and whitelist decisions must stay bound to the same controller identity and + // session scope across authentication retries. + fn login_scope_digest(lr: &LoginRequest) -> [u8; 32] { + let mut hasher = Sha256::new(); + // Length-prefixed so adjacent fields cannot alias. + let mut push = |bytes: &[u8]| { + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); + }; + push(lr.my_id.as_bytes()); + // Payloads are destructured exhaustively: a new field fails to compile until it is + // either latched here or deliberately ignored. + match lr.union.as_ref() { + Some(login_request::Union::FileTransfer(ft)) => { + let FileTransfer { + dir, + show_hidden, + special_fields: _, + } = ft; + push(b"file_transfer"); + push(dir.as_bytes()); + push(&[*show_hidden as u8]); + } + Some(login_request::Union::ViewCamera(vc)) => { + let ViewCamera { special_fields: _ } = vc; + push(b"view_camera"); + } + Some(login_request::Union::Terminal(t)) => { + let Terminal { + service_id, + special_fields: _, + } = t; + push(b"terminal"); + push(service_id.as_bytes()); + } + Some(login_request::Union::PortForward(pf)) => { + let PortForward { + host, + port, + special_fields: _, + } = pf; + push(b"port_forward"); + push(host.as_bytes()); + push(&port.to_le_bytes()); + } + // Variants this build does not know execute as remote, so they latch as remote. + None | Some(_) => push(b"remote"), + } + hasher.finalize().into() + } + + // Logging only; security decisions compare digests. + fn login_scope_kind(lr: &LoginRequest) -> &'static str { + match lr.union.as_ref() { + Some(login_request::Union::FileTransfer(_)) => "file_transfer", + Some(login_request::Union::ViewCamera(_)) => "view_camera", + Some(login_request::Union::Terminal(_)) => "terminal", + Some(login_request::Union::PortForward(_)) => "port_forward", + _ => "remote", + } + } + + async fn check_login_scope(&mut self, lr: &LoginRequest) -> bool { + let requested = Self::login_scope_digest(lr); + match self.login_scope { + Some(initial) if initial != requested => { + // self.lr still holds the first accepted request, whose scope is the latched one. + log::warn!( + "Rejected login scope change: conn_id={}, initial={}, requested={}", + self.inner.id(), + Self::login_scope_kind(&self.lr), + Self::login_scope_kind(lr), + ); + self.send_login_error("Connection not allowed").await; + false + } + Some(_) => true, + None => { + self.login_scope = Some(requested); + true + } + } + } + async fn handle_login_request_without_validation(&mut self, lr: &LoginRequest) { self.lr = lr.clone(); self.peer_argb = crate::str2color(&format!("{}{}", &lr.my_id, &lr.my_platform), 0xff); @@ -2698,6 +2786,9 @@ impl Connection { } // After handling CloseReason messages, proceed to process other message types if let Some(message::Union::LoginRequest(lr)) = msg.union { + if !self.check_login_scope(&lr).await { + return false; + } self.awaiting_2fa = false; self.handle_login_request_without_validation(&lr).await; if self.authorized { @@ -7003,6 +7094,59 @@ mod test { #[allow(unused)] use super::*; + #[test] + fn login_scope_latches_session_scope_across_login_retries() { + let port_forward = |host: &str| { + let mut lr = LoginRequest::new(); + lr.my_id = "peer".to_owned(); + lr.set_port_forward(PortForward { + host: host.to_owned(), + port: 3389, + ..Default::default() + }); + lr + }; + let first = port_forward("localhost"); + let scope = |lr: &LoginRequest| Connection::login_scope_digest(lr); + + // A retry may carry new credentials, profile data, options, and unknown fields. + let mut retry = port_forward("localhost"); + retry.password = "secret".into(); + retry.hwid = "hwid".into(); + retry.os_login = Some(OSLogin { + username: "admin".to_owned(), + ..Default::default() + }) + .into(); + retry.my_name = "New Display Name".to_owned(); + retry.avatar = "data:image/png;base64,AAAA".to_owned(); + retry + .special_fields + .mut_unknown_fields() + .add_varint(9999, 1); + assert_eq!(scope(&first), scope(&retry)); + + // It may not change the controller identity, move the target, or switch type. + let mut rotated_id = first.clone(); + rotated_id.my_id = "rotated-id".to_owned(); + assert_ne!(scope(&first), scope(&rotated_id)); + assert_ne!(scope(&first), scope(&port_forward("10.0.0.5"))); + let mut moved_port = port_forward("localhost"); + moved_port.mut_port_forward().port = 22; + assert_ne!(scope(&first), scope(&moved_port)); + let terminal = |service_id: &str| { + let mut lr = LoginRequest::new(); + lr.my_id = "peer".to_owned(); + lr.set_terminal(Terminal { + service_id: service_id.to_owned(), + ..Default::default() + }); + lr + }; + assert_ne!(scope(&first), scope(&terminal(""))); + assert_ne!(scope(&terminal("a")), scope(&terminal("b"))); + } + #[test] fn test_wildcard_match() { // Exact match. From d407db9faed8d7f45184fd8862b5dd728a2ae6fb Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:07:12 +0800 Subject: [PATCH 14/72] fix(client): allow switch-sides back-connection in incoming-only mode (#15780) * fix(client): allow switch-sides back-connection in incoming-only mode "Switch sides" makes the controlled client run `--connect --switch_uuid `, which Client::_start rejected outright in incoming-only custom clients, so the feature silently dropped the session and never switched. Exempt exactly that back-connection: a default-conn session carrying a switch uuid may proceed. The uuid is then verified against the local server process in handle_hash(); if it is missing there (forged or expired), an incoming-only client now aborts with an error instead of falling through to password login, so the outgoing-connection restriction cannot be bypassed with a crafted --switch_uuid. Fixes rustdesk/rustdesk#11200 (discussion) Co-Authored-By: Claude Fable 5 * fix(client): validate switch-back grants before connecting - check pending peer/UUID grants before bypassing incoming-only mode - close rejected switch-back connections and suppress retries - keep grant consumption in handle_hash and test non-consuming checks Signed-off-by: 21pages * fix(client): prevent switch-back UUID reuse - claim pending switch-back grants before connecting - retain claimed grants to reject duplicate requests - bind authorization to the peer ID and UUID - use a shared TTL for switch-back grants Signed-off-by: 21pages * fix(client): defer switch UUID consumption until authentication Signed-off-by: 21pages * fix(client): reject repeated hash login in incoming-only mode Signed-off-by: 21pages --------- Signed-off-by: 21pages Co-authored-by: Claude Fable 5 Co-authored-by: 21pages --- src/client.rs | 91 ++++++++++++++++++++++++++++++++++++++-- src/ipc.rs | 23 ++++++++-- src/server/connection.rs | 84 +++++++++++++++++++++++++++---------- 3 files changed, 169 insertions(+), 29 deletions(-) diff --git a/src/client.rs b/src/client.rs index 6f2347868..5f5f34cd0 100644 --- a/src/client.rs +++ b/src/client.rs @@ -252,7 +252,7 @@ impl Client { (i32, String), bool, )> { - if config::is_incoming_only() { + if config::is_incoming_only() && !is_switch_sides_back(conn_type, &interface).await { bail!("Incoming only mode"); } // to-do: remember the port for each peer, so that we can retry easier @@ -3455,9 +3455,55 @@ pub fn handle_login_error( } } +// "Switch sides" requires the incoming-only client to connect back to its +// controlling peer; verify the local pending uuid before opening the connection. #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] -async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool { +async fn is_switch_sides_back(conn_type: ConnType, interface: &impl Interface) -> bool { + if conn_type != ConnType::DEFAULT_CONN { + return false; + } + let (id, uuid) = { + let lch = interface.get_lch(); + let lc = lch.read().unwrap(); + let Some(uuid) = lc.switch_uuid.as_deref() else { + return false; + }; + let Ok(uuid) = Uuid::parse_str(uuid) else { + return false; + }; + (lc.id.clone(), uuid) + }; + if !request_local_switch_sides_uuid( + &id, + &uuid, + crate::ipc::SwitchSidesUuidAction::Check, + ) + .await + { + return false; + } + let lch = interface.get_lch(); + let lc = lch.read().unwrap(); + let current_uuid = lc + .switch_uuid + .as_deref() + .and_then(|value| Uuid::parse_str(value).ok()); + lc.id == id && current_uuid.as_ref() == Some(&uuid) +} + +#[cfg(not(all(feature = "flutter", not(any(target_os = "android", target_os = "ios")))))] +async fn is_switch_sides_back(_conn_type: ConnType, _interface: &impl Interface) -> bool { + false +} + +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +async fn request_local_switch_sides_uuid( + id: &str, + uuid: &Uuid, + action: crate::ipc::SwitchSidesUuidAction, +) -> bool { let Ok(mut conn) = crate::ipc::connect(1000, "").await else { return false; }; @@ -3466,6 +3512,7 @@ async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool { .send(&crate::ipc::Data::SwitchSidesUuid( uuid.clone(), id.to_owned(), + action, None, )) .await @@ -3477,9 +3524,10 @@ async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool { Ok(Some(crate::ipc::Data::SwitchSidesUuid( returned_uuid, returned_id, + returned_action, Some(true), ))) => { - returned_uuid == uuid && returned_id == id + returned_uuid == uuid && returned_id == id && returned_action == action } _ => false, } @@ -3512,7 +3560,13 @@ pub async fn handle_hash( if let Some(uuid) = uuid { if let Ok(uuid) = uuid::Uuid::from_str(&uuid) { let id = lc.read().unwrap().id.clone(); - if !consume_local_switch_sides_uuid(&id, &uuid).await { + if !request_local_switch_sides_uuid( + &id, + &uuid, + crate::ipc::SwitchSidesUuidAction::Consume, + ) + .await + { log::warn!("Ignored untrusted switch_uuid"); } else { lc.write().unwrap().allow_switch_back_once(); @@ -3522,6 +3576,19 @@ pub async fn handle_hash( } } } + // Incoming-only may connect out solely for a verified switch-back; + // never fall through to password login, including on repeated hashes. + if config::is_incoming_only() { + interface.msgbox("error", "Connection Error", "Incoming only mode", ""); + let mut misc = Misc::new(); + misc.set_close_reason( + "Connection not allowed in incoming-only mode".to_owned(), + ); + let mut msg = Message::new(); + msg.set_misc(misc); + allow_err!(peer.send(&msg).await); + return; + } } // last password let mut password = lc.read().unwrap().password.clone(); @@ -4031,9 +4098,25 @@ pub fn check_if_retry(msgtype: &str, title: &str, text: &str, retry_for_relay: b && !text.to_lowercase().contains("mismatch") && !text.to_lowercase().contains("manually") && !text.to_lowercase().contains("restricted") + && !text.to_lowercase().contains("incoming only") && !text.to_lowercase().contains("not allowed"))) } +#[cfg(test)] +mod retry_tests { + use super::check_if_retry; + + #[test] + fn incoming_only_error_is_not_retryable() { + assert!(!check_if_retry( + "error", + "Connection Error", + "Incoming only mode", + false, + )); + } +} + pub async fn hc_connection( feedback: i32, rendezvous_server: String, diff --git a/src/ipc.rs b/src/ipc.rs index b3abeeb55..52e79955d 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -312,6 +312,14 @@ pub enum DataPortableService { CmShowElevation(bool), } +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +pub enum SwitchSidesUuidAction { + Check, + Consume, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "t", content = "c")] pub enum Data { @@ -387,7 +395,7 @@ pub enum Data { SwitchSidesRequest(String), #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] - SwitchSidesUuid(String, String, Option), + SwitchSidesUuid(String, String, SwitchSidesUuidAction, Option), #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] SwitchSidesBack, @@ -1050,14 +1058,21 @@ async fn handle(data: Data, stream: &mut Connection) { } #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] - Data::SwitchSidesUuid(uuid, id, None) => { + Data::SwitchSidesUuid(uuid, id, action, None) => { let allowed = uuid .parse::() - .map(|uuid| crate::server::remove_pending_switch_sides_uuid(&id, &uuid)) + .map(|uuid| match action { + SwitchSidesUuidAction::Check => { + crate::server::has_pending_switch_sides_uuid(&id, &uuid) + } + SwitchSidesUuidAction::Consume => { + crate::server::claim_pending_switch_sides_uuid(&id, &uuid) + } + }) .unwrap_or(false); allow_err!( stream - .send(&Data::SwitchSidesUuid(uuid, id, Some(allowed))) + .send(&Data::SwitchSidesUuid(uuid, id, action, Some(allowed))) .await ); } diff --git a/src/server/connection.rs b/src/server/connection.rs index 90729fb8d..b461a3eff 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -91,11 +91,15 @@ lazy_static::lazy_static! { static ref WAKELOCK_KEEP_AWAKE_OPTION: Arc::>> = Default::default(); } +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +const SWITCH_SIDES_UUID_TTL: Duration = Duration::from_secs(10); + #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] lazy_static::lazy_static! { static ref SWITCH_SIDES_UUID: Arc::>> = Default::default(); - static ref PENDING_SWITCH_SIDES_UUID: Arc::>> = Default::default(); + static ref PENDING_SWITCH_SIDES_UUID: Arc::>> = Default::default(); } #[cfg(target_os = "windows")] @@ -3085,7 +3089,7 @@ impl Connection { SWITCH_SIDES_UUID .lock() .unwrap() - .retain(|_, v| v.0.elapsed() < Duration::from_secs(10)); + .retain(|_, v| v.0.elapsed() < SWITCH_SIDES_UUID_TTL); let uuid_old = SWITCH_SIDES_UUID.lock().unwrap().remove(&lr.my_id); if let Ok(uuid) = uuid::Uuid::from_slice(_s.uuid.to_vec().as_ref()) { if let Some((_instant, uuid_old)) = uuid_old { @@ -3825,17 +3829,18 @@ impl Connection { #[cfg(not(any(target_os = "android", target_os = "ios")))] Some(misc::Union::SwitchSidesRequest(s)) => { if let Ok(uuid) = uuid::Uuid::from_slice(&s.uuid.to_vec()[..]) { - crate::server::insert_pending_switch_sides_uuid( + if crate::server::insert_pending_switch_sides_uuid( self.lr.my_id.clone(), uuid.clone(), - ); - crate::run_me(vec![ - "--connect", - &self.lr.my_id, - "--switch_uuid", - uuid.to_string().as_ref(), - ]) - .ok(); + ) { + crate::run_me(vec![ + "--connect", + &self.lr.my_id, + "--switch_uuid", + uuid.to_string().as_ref(), + ]) + .ok(); + } self.on_close("switch sides", false).await; return false; } @@ -6139,23 +6144,40 @@ pub fn insert_switch_sides_uuid(id: String, uuid: uuid::Uuid) { #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] -pub fn insert_pending_switch_sides_uuid(id: String, uuid: uuid::Uuid) { +pub fn insert_pending_switch_sides_uuid(id: String, uuid: uuid::Uuid) -> bool { let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap(); - uuids.retain(|_, (instant, _)| instant.elapsed() < Duration::from_secs(10)); - uuids.insert(id, (tokio::time::Instant::now(), uuid)); + uuids.retain(|_, (instant, _, _)| instant.elapsed() < SWITCH_SIDES_UUID_TTL); + if uuids.get(&id).map(|(_, stored_uuid, _)| stored_uuid) == Some(&uuid) { + return false; + } + uuids.insert(id, (tokio::time::Instant::now(), uuid, false)); + true } #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] -pub fn remove_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool { +pub fn has_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool { let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap(); - uuids.retain(|_, (instant, _)| instant.elapsed() < Duration::from_secs(10)); - if uuids.get(id).map(|(_, stored_uuid)| stored_uuid == uuid) == Some(true) { - uuids.remove(id); - true - } else { - false + uuids.retain(|_, (instant, _, _)| instant.elapsed() < SWITCH_SIDES_UUID_TTL); + uuids + .get(id) + .map(|(_, stored_uuid, claimed)| stored_uuid == uuid && !*claimed) + == Some(true) +} + +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn claim_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool { + let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap(); + uuids.retain(|_, (instant, _, _)| instant.elapsed() < SWITCH_SIDES_UUID_TTL); + // Keep claimed entries until expiry so replaying a request cannot launch another connection. + if let Some((_, stored_uuid, claimed)) = uuids.get_mut(id) { + if stored_uuid == uuid && !*claimed { + *claimed = true; + return true; + } } + false } #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -7094,6 +7116,26 @@ mod test { #[allow(unused)] use super::*; + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[test] + fn test_pending_switch_sides_uuid_is_claimed_once() { + let id = uuid::Uuid::new_v4().to_string(); + let uuid = uuid::Uuid::new_v4(); + let other_uuid = uuid::Uuid::new_v4(); + assert!(insert_pending_switch_sides_uuid(id.clone(), uuid.clone())); + + assert!(!insert_pending_switch_sides_uuid(id.clone(), uuid.clone())); + assert!(has_pending_switch_sides_uuid(&id, &uuid)); + assert!(!has_pending_switch_sides_uuid(&id, &other_uuid)); + assert!(!claim_pending_switch_sides_uuid("other-peer", &uuid)); + assert!(!claim_pending_switch_sides_uuid(&id, &other_uuid)); + assert!(claim_pending_switch_sides_uuid(&id, &uuid)); + assert!(!has_pending_switch_sides_uuid(&id, &uuid)); + assert!(!claim_pending_switch_sides_uuid(&id, &uuid)); + assert!(!insert_pending_switch_sides_uuid(id, uuid)); + } + #[test] fn login_scope_latches_session_scope_across_login_retries() { let port_forward = |host: &str| { From 947cb3f17b673b55dfcbe95b318749f8b44a7f7a Mon Sep 17 00:00:00 2001 From: rustdesk Date: Mon, 10 Aug 2026 16:45:27 +0800 Subject: [PATCH 15/72] propagates the hash-handler continuation result through both connection loops, allowing incoming-only rejection to terminate the connection while preserving existing login flows. --- src/client.rs | 11 ++++++----- src/client/io_loop.rs | 8 ++++++-- src/port_forward.rs | 4 +++- src/ui_session_interface.rs | 4 ++-- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/client.rs b/src/client.rs index 5f5f34cd0..e1e4c8034 100644 --- a/src/client.rs +++ b/src/client.rs @@ -3548,7 +3548,7 @@ pub async fn handle_hash( hash: Hash, interface: &impl Interface, peer: &mut Stream, -) { +) -> bool { lc.write().unwrap().hash = hash.clone(); // Take care of password application order @@ -3572,7 +3572,7 @@ pub async fn handle_hash( lc.write().unwrap().allow_switch_back_once(); send_switch_login_request(lc.clone(), peer, uuid).await; lc.write().unwrap().password_source = Default::default(); - return; + return true; } } } @@ -3587,7 +3587,7 @@ pub async fn handle_hash( let mut msg = Message::new(); msg.set_misc(misc); allow_err!(peer.send(&msg).await); - return; + return false; } } // last password @@ -3651,7 +3651,7 @@ pub async fn handle_hash( interface.msgbox("terminal-admin-login", "", "", ""); } lc.write().unwrap().hash = hash; - return; + return true; } let password = if password.is_empty() { @@ -3677,6 +3677,7 @@ pub async fn handle_hash( send_login(lc.clone(), os_username, os_password, password, peer).await; lc.write().unwrap().hash = hash; + true } #[inline] @@ -3804,7 +3805,7 @@ pub trait Interface: Send + Clone + 'static + Sized { fn on_error(&self, err: &str) { self.msgbox("error", "Error", err, ""); } - async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream); + async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) -> bool; async fn handle_login_from_ui( &self, os_username: String, diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index 4636c54f8..1af691429 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -1353,9 +1353,13 @@ impl Remote { } } Some(message::Union::Hash(hash)) => { - self.handler + if !self + .handler .handle_hash(&self.handler.password.clone(), hash, peer) - .await; + .await + { + return false; + } } Some(message::Union::LoginResponse(lr)) => match lr.union { Some(login_response::Union::Error(err)) => { diff --git a/src/port_forward.rs b/src/port_forward.rs index 8b190fb1e..7a3f8715c 100644 --- a/src/port_forward.rs +++ b/src/port_forward.rs @@ -150,7 +150,9 @@ async fn connect_and_login( let msg_in = Message::parse_from_bytes(&bytes)?; match msg_in.union { Some(message::Union::Hash(hash)) => { - interface.handle_hash(password, hash, &mut stream).await; + if !interface.handle_hash(password, hash, &mut stream).await { + return Ok(None); + } } Some(message::Union::LoginResponse(lr)) => match lr.union { Some(login_response::Union::Error(err)) => { diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index bf2e04c6b..9e4128dca 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -1878,8 +1878,8 @@ impl Interface for Session { } } - async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) { - handle_hash(self.lc.clone(), pass, hash, self, peer).await; + async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) -> bool { + handle_hash(self.lc.clone(), pass, hash, self, peer).await } async fn handle_login_from_ui( From ff07ff7f13a7c4a350519243b803759207978817 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:54:57 +0800 Subject: [PATCH 16/72] =?UTF-8?q?fix(terminal):=20send=20SGR=20mouse=20whe?= =?UTF-8?q?el=20reports=20with=20the=20button=20codes=20app=E2=80=A6=20(#1?= =?UTF-8?q?5817)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): send SGR mouse wheel reports with the button codes apps expect xterm.dart 4.0.0 encodes the wheel buttons as 64+4..64+7 rather than 64+0..64+3, so the low bits land on the modifier field and every wheel report the terminal emits reads as wheel-with-Shift. Strict full-screen applications reject the modified event, which is why neither the mouse wheel nor the trackpad scrolls anything once the peer application takes over the alternate screen. Install a mouse handler that keeps every upstream reporting decision and only re-encodes the wheel buttons as 64..67. Non-wheel reports pass through untouched, and the emitted bytes stay identical once upstream ships the same fix, so this can be dropped without a behavior change. Upstream: TerminalStudio/xterm.dart#238 Co-Authored-By: Claude Fable 5 * fix(terminal): correct the wheel report row, drop the wasted report build Address review feedback on the wheel button fix: - The X10/utf row was encoded as `32 + y + 1` while y is already 1-based, so every normal-mode report pointed one row too low and the `y > limit` guard disagreed with what it emitted. - Gate the wheel path on `mouseMode.reportScroll` and the button state instead of building and discarding a full report string from `defaultMouseHandler` on every scroll tick. This also makes the hardcoded SGR 'M' provably right, since a wheel release now returns before the report is built. - Derive the wire code as `id - 4` and drop `_wheelButtonId`, whose `default` branch was unreachable and defeated enum exhaustiveness. - Assign `mouseHandler` after construction so the `Terminal(...)` line stays untouched. Cover the utf, urxvt, null-byte overflow and click-only branches, and assert that TerminalModel actually installs the handler. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- flutter/lib/models/terminal_model.dart | 2 + .../lib/models/terminal_mouse_handler.dart | 42 +++++++ .../test/terminal_model_lifecycle_test.dart | 17 +++ flutter/test/terminal_mouse_handler_test.dart | 114 ++++++++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 flutter/lib/models/terminal_mouse_handler.dart create mode 100644 flutter/test/terminal_mouse_handler_test.dart diff --git a/flutter/lib/models/terminal_model.dart b/flutter/lib/models/terminal_model.dart index 6f179afe2..0472cb483 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -10,6 +10,7 @@ import 'package:xterm/xterm.dart'; import 'input_modifier_utils.dart'; import 'model.dart'; import 'platform_model.dart'; +import 'terminal_mouse_handler.dart'; class TerminalModel with ChangeNotifier { final String id; // peer id @@ -129,6 +130,7 @@ class TerminalModel with ChangeNotifier { TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id { terminal = Terminal(maxLines: 10000); + terminal.mouseHandler = const WheelButtonFixMouseHandler(); terminalController = TerminalController(); // Setup terminal callbacks diff --git a/flutter/lib/models/terminal_mouse_handler.dart b/flutter/lib/models/terminal_mouse_handler.dart new file mode 100644 index 000000000..a6a617488 --- /dev/null +++ b/flutter/lib/models/terminal_mouse_handler.dart @@ -0,0 +1,42 @@ +import 'package:xterm/xterm.dart'; + +/// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift +/// modifier, so strict full-screen apps ignore the report and never scroll. +/// Upstream fix: TerminalStudio/xterm.dart#238. +class WheelButtonFixMouseHandler implements TerminalMouseHandler { + const WheelButtonFixMouseHandler(); + + @override + String? call(TerminalMouseEvent event) { + if (!event.button.isWheel) { + return defaultMouseHandler(event); + } + // Same gate as UpDownMouseHandler: only the scroll modes report a wheel, + // and a wheel release is never reported, so the report is always a press. + if (!event.state.mouseMode.reportScroll || + event.buttonState == TerminalMouseButtonState.up) { + return null; + } + return _reportWheel(event); + } + + String _reportWheel(TerminalMouseEvent event) { + // Wheel buttons 4..7 go on the wire as 64..67, but `id` is 64 + 4..7. + final button = event.button.id - 4; + final x = event.position.x + 1; + final y = event.position.y + 1; + switch (event.state.mouseReportMode) { + case MouseReportMode.normal: + case MouseReportMode.utf: + final limit = + event.state.mouseReportMode == MouseReportMode.normal ? 223 : 2015; + final col = x > limit ? '\x00' : String.fromCharCode(32 + x); + final row = y > limit ? '\x00' : String.fromCharCode(32 + y); + return '\x1b[M${String.fromCharCode(32 + button)}$col$row'; + case MouseReportMode.sgr: + return '\x1b[<$button;$x;${y}M'; + case MouseReportMode.urxvt: + return '\x1b[${32 + button};$x;${y}M'; + } + } +} diff --git a/flutter/test/terminal_model_lifecycle_test.dart b/flutter/test/terminal_model_lifecycle_test.dart index d00646b2b..5581886b7 100644 --- a/flutter/test/terminal_model_lifecycle_test.dart +++ b/flutter/test/terminal_model_lifecycle_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter_hbb/models/model.dart'; import 'package:flutter_hbb/models/terminal_model.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/xterm.dart'; class _FakeFFI implements FFI { @override @@ -48,4 +49,20 @@ void main() { expect(clearedCtrlLock, isFalse); expect(model.debugBufferedInputCount, 0); }); + + test('builds its terminal with the wheel button fix', () { + final model = TerminalModel(_FakeFFI()); + addTearDown(model.dispose); + + final captured = []; + model.terminal.onOutput = captured.add; + model.terminal.write('\x1b[?1000h\x1b[?1006h'); + model.terminal.mouseInput( + TerminalMouseButton.wheelUp, + TerminalMouseButtonState.down, + const CellOffset(10, 5), + ); + + expect(captured.single, '\x1b[<64;11;6M'); + }); } diff --git a/flutter/test/terminal_mouse_handler_test.dart b/flutter/test/terminal_mouse_handler_test.dart new file mode 100644 index 000000000..3fae7f71d --- /dev/null +++ b/flutter/test/terminal_mouse_handler_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter_hbb/models/terminal_mouse_handler.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/xterm.dart'; + +void main() { + late Terminal terminal; + late List output; + + setUp(() { + output = []; + terminal = Terminal(mouseHandler: const WheelButtonFixMouseHandler()) + ..onOutput = output.add; + }); + + String? report( + TerminalMouseButton button, [ + TerminalMouseButtonState state = TerminalMouseButtonState.down, + CellOffset position = const CellOffset(10, 5), + ]) { + output.clear(); + terminal.mouseInput(button, state, position); + return output.isEmpty ? null : output.single; + } + + test('reports SGR wheel buttons without the Shift modifier bit', () { + terminal.write('\x1b[?1000h\x1b[?1006h'); + + expect(report(TerminalMouseButton.wheelUp), '\x1b[<64;11;6M'); + expect(report(TerminalMouseButton.wheelDown), '\x1b[<65;11;6M'); + expect(report(TerminalMouseButton.wheelLeft), '\x1b[<66;11;6M'); + expect(report(TerminalMouseButton.wheelRight), '\x1b[<67;11;6M'); + }); + + test('reports normal-encoding wheel buttons in the 64..67 range', () { + terminal.write('\x1b[?1000h'); + + expect( + report(TerminalMouseButton.wheelUp), + '\x1b[M${String.fromCharCode(32 + 64)}' + '${String.fromCharCode(32 + 11)}${String.fromCharCode(32 + 6)}', + ); + expect( + report(TerminalMouseButton.wheelDown), + '\x1b[M${String.fromCharCode(32 + 65)}' + '${String.fromCharCode(32 + 11)}${String.fromCharCode(32 + 6)}', + ); + }); + + test('reports utf-encoding wheel buttons beyond the normal-mode range', () { + terminal.write('\x1b[?1000h\x1b[?1005h'); + + expect( + report( + TerminalMouseButton.wheelDown, + TerminalMouseButtonState.down, + const CellOffset(400, 300), + ), + '\x1b[M${String.fromCharCode(32 + 65)}' + '${String.fromCharCode(32 + 401)}${String.fromCharCode(32 + 301)}', + ); + }); + + test('reports urxvt-encoding wheel buttons shifted by 32', () { + terminal.write('\x1b[?1000h\x1b[?1015h'); + + expect(report(TerminalMouseButton.wheelUp), '\x1b[96;11;6M'); + expect(report(TerminalMouseButton.wheelDown), '\x1b[97;11;6M'); + }); + + test('sends a null byte for coordinates past the encoding limit', () { + terminal.write('\x1b[?1000h'); + + expect( + report( + TerminalMouseButton.wheelUp, + TerminalMouseButtonState.down, + const CellOffset(300, 300), + ), + '\x1b[M${String.fromCharCode(32 + 64)}\x00\x00', + ); + }); + + test('leaves non-wheel buttons to the upstream handler', () { + terminal.write('\x1b[?1000h\x1b[?1006h'); + + expect(report(TerminalMouseButton.left), '\x1b[<0;11;6M'); + expect(report(TerminalMouseButton.middle), '\x1b[<1;11;6M'); + expect( + report(TerminalMouseButton.right, TerminalMouseButtonState.up), + '\x1b[<2;11;6m', + ); + }); + + test('stays silent when the peer has not enabled mouse reporting', () { + expect(report(TerminalMouseButton.wheelDown), isNull); + expect(report(TerminalMouseButton.left), isNull); + }); + + test('stays silent for the wheel in click-only mode', () { + terminal.write('\x1b[?9h\x1b[?1006h'); + + expect(report(TerminalMouseButton.wheelDown), isNull); + expect(report(TerminalMouseButton.left), '\x1b[<0;11;6M'); + }); + + test('does not report wheel button releases', () { + terminal.write('\x1b[?1000h\x1b[?1006h'); + + expect( + report(TerminalMouseButton.wheelDown, TerminalMouseButtonState.up), + isNull, + ); + }); +} From 23256e6ac1687ba63c38a3d55a64e1b04c8c637a Mon Sep 17 00:00:00 2001 From: "Chen, Ting-An" <73953029+nrps9909@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:00:05 +0800 Subject: [PATCH 17/72] fix(i18n): complete Traditional Chinese sign-in strings (#15829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 陳廷安 <73953029+nrps9909@users.noreply.github.com> --- src/lang/tw.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 0401d80b7..438cb8091 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -773,7 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "你的 IP 已被對方封鎖"), ("id_whitelist_caveat_tip", "ID 由對端用戶端回報,白名單用於減少暴露面,不能取代密碼或 2FA"), ("whitelist_cidr_tip", "支援 CIDR 寫法,例如 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), + ("Continue", "繼續"), + ("Browser didn't open? Use the url below to sign in.", "瀏覽器未開啟?請使用下方網址登入。"), ].iter().cloned().collect(); } From 1d09760ef7c9275555ac512d66fee5af549c5d06 Mon Sep 17 00:00:00 2001 From: fufesou Date: Tue, 11 Aug 2026 15:54:03 +0800 Subject: [PATCH 18/72] fix(terminal): keep selection aligned after clearing scrollback (#15831) Remove scrollback lines through the index-aware buffer operation so deleted anchors are detached and retained lines are reindexed. Signed-off-by: fufesou --- flutter/lib/models/rustdesk_terminal.dart | 14 ++++++++++++++ flutter/lib/models/terminal_model.dart | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 flutter/lib/models/rustdesk_terminal.dart diff --git a/flutter/lib/models/rustdesk_terminal.dart b/flutter/lib/models/rustdesk_terminal.dart new file mode 100644 index 000000000..6e3f35dfd --- /dev/null +++ b/flutter/lib/models/rustdesk_terminal.dart @@ -0,0 +1,14 @@ +import 'package:xterm/xterm.dart'; + +class RustDeskTerminal extends Terminal { + RustDeskTerminal({super.maxLines}); + + @override + void eraseScrollbackOnly() { + final scrollBack = buffer.scrollBack; + if (scrollBack == 0) return; + + // Selection anchors require retained buffer lines to be reindexed. + buffer.lines.remove(0, scrollBack); + } +} diff --git a/flutter/lib/models/terminal_model.dart b/flutter/lib/models/terminal_model.dart index 0472cb483..63e831202 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -10,6 +10,7 @@ import 'package:xterm/xterm.dart'; import 'input_modifier_utils.dart'; import 'model.dart'; import 'platform_model.dart'; +import 'rustdesk_terminal.dart'; import 'terminal_mouse_handler.dart'; class TerminalModel with ChangeNotifier { @@ -129,7 +130,7 @@ class TerminalModel with ChangeNotifier { } TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id { - terminal = Terminal(maxLines: 10000); + terminal = RustDeskTerminal(maxLines: 10000); terminal.mouseHandler = const WheelButtonFixMouseHandler(); terminalController = TerminalController(); From 63822048df9c86ecb3b6bd9000a130a7a0919f2f Mon Sep 17 00:00:00 2001 From: Anupam Mediratta Date: Wed, 12 Aug 2026 12:00:43 +0530 Subject: [PATCH 19/72] fix: upgrade fuser to 0.16.0 (GHSA-cvmj-47v9-35m9) (#15834) FUSE-Rust: Uninitalized memory read and leak caused by fuser crate Resolves GHSA-cvmj-47v9-35m9 Signed-off-by: anupamme --- Cargo.lock | 4 ++-- libs/clipboard/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93e1a6837..479307ec3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3074,9 +3074,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "fuser" -version = "0.15.1" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369" +checksum = "0bb29a3ae32279fe3e79a958fe01899f5fb23eadccee919cf88e145b54ed9367" dependencies = [ "libc", "log", diff --git a/libs/clipboard/Cargo.toml b/libs/clipboard/Cargo.toml index afe2f2f31..9bb5e789f 100644 --- a/libs/clipboard/Cargo.toml +++ b/libs/clipboard/Cargo.toml @@ -43,7 +43,7 @@ once_cell = {version = "1.18", optional = true} percent-encoding = {version ="2.3", optional = true} x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true} x11rb = {version = "0.12", features = ["all-extensions"], optional = true} -fuser = {version = "0.15", default-features = false, optional = true} +fuser = {version = "0.16", default-features = false, optional = true} [target.'cfg(target_os = "macos")'.dependencies] cacao = {git="https://github.com/clslaid/cacao", branch = "feat/set-file-urls", optional = true} From 10bcf976f7cf3ebe0a4e196dbd2b1c89e85dcac7 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:12:46 +0800 Subject: [PATCH 20/72] Revert "fix: upgrade fuser to 0.16.0 (GHSA-cvmj-47v9-35m9) (#15834)" (#15841) This reverts commit 63822048df9c86ecb3b6bd9000a130a7a0919f2f. --- Cargo.lock | 4 ++-- libs/clipboard/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 479307ec3..93e1a6837 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3074,9 +3074,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "fuser" -version = "0.16.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb29a3ae32279fe3e79a958fe01899f5fb23eadccee919cf88e145b54ed9367" +checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369" dependencies = [ "libc", "log", diff --git a/libs/clipboard/Cargo.toml b/libs/clipboard/Cargo.toml index 9bb5e789f..afe2f2f31 100644 --- a/libs/clipboard/Cargo.toml +++ b/libs/clipboard/Cargo.toml @@ -43,7 +43,7 @@ once_cell = {version = "1.18", optional = true} percent-encoding = {version ="2.3", optional = true} x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true} x11rb = {version = "0.12", features = ["all-extensions"], optional = true} -fuser = {version = "0.16", default-features = false, optional = true} +fuser = {version = "0.15", default-features = false, optional = true} [target.'cfg(target_os = "macos")'.dependencies] cacao = {git="https://github.com/clslaid/cacao", branch = "feat/set-file-urls", optional = true} From dfca2c1b8f401b24c231cca75b155ed6aa3b518c Mon Sep 17 00:00:00 2001 From: rustdesk Date: Wed, 12 Aug 2026 17:28:59 +0800 Subject: [PATCH 21/72] update agents.md --- AGENTS.md | 1 - 1 file changed, 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 4ff5b1e75..7ab98087d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,6 @@ * Keep them short: one line by default, three at most. * Say **why**, never what. If the code already says it, delete the comment. -* Do not document rejected alternatives, past bugs, measurements, or how you arrived at the code. That belongs in the commit message or the PR. * A comment must never be longer than the code it describes. * Applies to YAML, shell and Python too, not just Rust. From c4fd7d692dc657e3bc87e1f75f3308e9cd426987 Mon Sep 17 00:00:00 2001 From: fufesou Date: Wed, 12 Aug 2026 21:36:06 +0800 Subject: [PATCH 22/72] refact: fuser 0.16.0, cargo 1.75.0 (#15844) Signed-off-by: fufesou --- Cargo.lock | 5 ++--- libs/clipboard/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93e1a6837..cb08cdad2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3074,9 +3074,8 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "fuser" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369" +version = "0.16.0" +source = "git+https://github.com/rustdesk-org/fuser?branch=refact/tag-0.16.0-cargo-1.75.0#a3c0babe4a533f8dbcff5bce59ae7f2424b8d877" dependencies = [ "libc", "log", diff --git a/libs/clipboard/Cargo.toml b/libs/clipboard/Cargo.toml index afe2f2f31..7e15791e9 100644 --- a/libs/clipboard/Cargo.toml +++ b/libs/clipboard/Cargo.toml @@ -43,7 +43,7 @@ once_cell = {version = "1.18", optional = true} percent-encoding = {version ="2.3", optional = true} x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true} x11rb = {version = "0.12", features = ["all-extensions"], optional = true} -fuser = {version = "0.15", default-features = false, optional = true} +fuser = {git="https://github.com/rustdesk-org/fuser", branch = "refact/tag-0.16.0-cargo-1.75.0", default-features = false, optional = true} [target.'cfg(target_os = "macos")'.dependencies] cacao = {git="https://github.com/clslaid/cacao", branch = "feat/set-file-urls", optional = true} From d829d1410a49123fcf4209496a4788f4cc02eec5 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Thu, 13 Aug 2026 09:22:41 -0300 Subject: [PATCH 23/72] fix(linux): serve the Wayland login screen the DRM backend was built for (#15792) * fix(linux): serve the Wayland login screen the DRM backend was built for The login screen support in #15420 never worked on a real greeter. fufesou found it: the session is refused, and with the refusal commented out the client gets a failed connection instead of a screen. One premise under all of it. `get_values_of_seat0` is `_get_values_of_seat0(.., ignore_gdm_wayland = true)`, so a gdm/sddm Wayland session is skipped by construction and `get_display_server` falls back to x11. That was correct while the portal was the only backend, since the portal cannot serve a greeter at all. The DRM path never talks to the compositor, which is precisely why it can serve one, so the premise stops holding there and every x11-vs-Wayland decision in the tree answers x11 at a login screen. The central change is the memoised `IS_X11`: when it reads x11 and seat0 is a Wayland greeter, answer Wayland. That covers fifteen routing sites at once, and it is under `cfg(feature = "drm")`, so a build without the backend keeps the current answer exactly. `is_x11_for_drm` is the unmemoised form for the two retry loops that must keep asking while a boot is still naming the session, and the memoised accessor is scoped to per-frame callers in the per-session `--server`, which the service only spawns once it has identified the session. Input was the last layer and lived outside all of that. `Enigo` decides x11-vs-Wayland once in `Default::default()`, from the same seat0 lookup, and on "x11" routes every key and mouse event to xdo; with no X server that context is null and libxdo drops them without an error. So the uinput devices were created, the compositor opened them, and nothing was ever written to them. `set_is_x11` is now called where the custom devices are installed, which is only reached once `!is_x11()` is already established. The unit test pins both directions, since a one-directional test passes against the bug. With no compositor reachable, the uinput desktop rect comes from the DRM display list instead: those are the same displays being captured, so the coordinate space matches by construction. Telling the truth about a greeter also makes four compositor-probing paths reachable where the probe cannot answer; all four already treat an empty output list as "nothing to do", so they skip it and 11818 "Could not find wayland compositor" warnings in one session became 1. Tested on an sddm Plasma Wayland greeter, MacBook T2, 2880x1800: the greeter renders, typing from the client enters characters in the password field, a click at an absolute coordinate opens the greeter session combo, the service pre-warm primes in 994 us instead of timing out, and the privileged service maps no EGL during a live capture. Not proven on gdm under Wayland. Known limitations: non-ASCII characters cannot be typed at a greeter, because that path goes through the clipboard and the clipboard here is X11 only; and at a multi-monitor greeter the pointer reaches the first display only, since every DRM output reports origin (0,0) on Wayland and there is no arrangement to derive without the compositor. * fix(linux): a Wayland greeter the DRM backend can serve is not headless fufesou reported the login screen still failing on Ubuntu 24.04 with gdm3, with the client asking for OS credentials to start an X session instead of showing the greeter. Reproduced on a real gdm greeter here. Same premise as the rest of the branch, one more consumer. `DesktopManager::new` reads seat0 through `get_values_of_seat0`, which skips a gdm/sddm Wayland session by construction, so at a greeter it finds no session at all and `get_supported_display_seat0_username` returns None from its empty-username arm. That makes `is_headless()` true, so the service advertises headless and `try_start_desktop` answers `LOGIN_MSG_DESKTOP_SESSION_NOT_READY`. The corrected `IS_X11` does not reach this one: it asks who owns seat0, not which display server is running. So ask again, with the greeter visible, when the DRM backend can capture and inject into it. At query time rather than in `new()`, because the DRM probe has not necessarily settled when the desktop manager is constructed, and the answer would latch for the process lifetime. In a normal session the latched username is a real user and the extra read is skipped. * chore: drop the hbb_common bump, this branch does not need it The bump carried rustdesk/hbb_common#580, the compositor-socket fallback. Nothing here depends on it: the greeter paths in this branch are the ones that run when compositor data is unavailable, which is what the commit before this one states as a known limitation. Keeping the bump would only block the greeter fix behind a review of a separate change, and would import that change's blocking review items into this path. * fix(linux): let the uinput uid gate see the greeter that owns seat0 Input at a real greeter was rejected by our own authorization. Measured on Ubuntu 24.04 with gdm3: the root service logs Rejected unauthorized connection on uinput ipc channel: postfix=_uinput_control, peer_uid=Some(120), active_uid=None and the greeter's `--server` gets ECONNRESET out of `setup_uinput`, so no uinput device is ever created and neither keyboard nor mouse reaches the greeter. uid 120 is gdm, the owner of the only active seat0 session. `active_uid` is None because the uinput authorizer deliberately bypasses the service-loop cache and takes a fresh seat0 lookup, and the fresh read hides a Wayland greeter by construction. The cache-based gates do not have the problem: `Desktop::refresh` fills it through the greeter-visible read, which is also why capture and config sync work at a greeter while input does not. So make the fresh read agree with the cache. It keeps the property the uinput gate wants, a lookup that cannot be stale, and it still compares the peer against the uid of the session that owns seat0 -- which at a greeter is the greeter. * fix: settle the DRM probe before routing login to X11, and read seat0 fresh Two findings from the #15792 review, both verified against the code: - drm_login_screen_seat0_username asked the cached probe, so a client arriving before warm_availability publishes its verdict read "no DRM" and, with allow-linux-headless=Y, try_start_x_session could start Xorg over a live Wayland greeter. Ask the probing form instead, and only after the cheap seat0 read says a Wayland greeter is actually there: a bounded definitive verdict is affordable on a login-time path. - get_supported_display_seat0_username trusted the seat0 values cached in DesktopManager::new(), which go stale across a logout or a fast user switch: a stale non-greeter name skipped the greeter probe and was returned as the supported display owner. Read seat0 fresh on every query; every call site is connection-time, so the extra loginctl read is cheap. Regression-tested on a real sddm Wayland greeter: capture streams the greeter, the RustDesk password dialog is the only prompt, and five typed characters appeared in the greeter password field over uinput with zero "Rejected unauthorized connection" lines in the service log. * fix: ask the greeter compositor for the multi-monitor layout The display arrangement and the pointer mapping were wrong at a multi-monitor login screen, and the mechanism is measured on a two-head virtio VM: DRM has no origins, so every display was advertised at (0,0) (a stacked arrangement on the client), and the uinput range was taken from the union of the DRM modes while the compositor had arranged the outputs side by side. Both came from the same premise, written before the hbb_common socket fallback existed: "a login screen has no compositor to ask". wayland_outputs_askable() skipped the wl_output augmentation at any greeter, and update_uinput_resolution took the DRM union directly. The premise is false now: a greeter runs a compositor, and the socket fallback reaches it with no environment variables, measured answering two outputs at the VM greeter while the old gate was still routing around it. Drop the gate and take the compositor-first path everywhere. Where the fallback cannot answer, the output list comes back empty and both call sites degrade to exactly the old behavior, so a build against an older hbb_common is unchanged. * fix: augment a single display too, and probe the desktop rect off the executor Two follow-ups from the automated re-review of cd80c3dee, both verified: - augment_with_wayland_geometry skipped the compositor below two DRM displays, but on a multi-GPU host the one connector this service can open may sit at a non-zero origin of the compositor layout, and DRM alone reports (0,0). - the desktop rect for uinput can now block for the socket probe deadline, and update_uinput_resolution runs on current-thread runtimes; move the query into spawn_blocking. The third re-review finding, the warm-up allegedly skipping Wayland greeters, is refuted: warm_availability probes while is_x11_for_drm() is false, which includes a Wayland greeter, and the greeter log of the VM run behind cd80c3dee shows the warm succeeding there. * fix: baseline the layout from the blocking task, and augment a lone output's origin The layout snapshot after the rect lookup still ran on the executor: a failed compositor lookup is not cached, so the snapshot synchronously repeated the whole socket probe there. The baseline is now computed inside the same blocking task, from the snapshot the successful lookup just cached, or omitted when only the raw DRM union was available, which keeps the #15601 remap inactive exactly where origins are unknown. A single compositor output now hands its origin to a single connector: the lone output can sit at a non-zero origin the DRM side cannot see. Scale stays 1 on purpose, matching how a single display is advertised at physical size, and more connectors than the one output stays unaugmented, since the layout-order fallback would plant that origin on a guess. Also refresh the get_primary_index doc that still said augmentation declines below two connectors. * fix: read the DRM probe as a tri-state, and keep pre-auth seat0 checks cache-only is_available() answered false both for a definitive no-DRM verdict and for a probe that had simply not settled (another probe in flight, or a failure still below the disable threshold), and the login-screen decision turned that transient false into no-greeter: try_start_x_session could put Xorg over a live greeter in exactly the window the probe needed. The machinery now answers Available/Unavailable/Unsettled, and only a definitive Unavailable routes the seat toward X11. Connection setup also ran the whole lookup pre-auth: constructing LinuxHeadlessHandle called is_headless() before authentication, holding DESKTOP_MANAGER while loginctl ran and, at a greeter, while the DRM probe waited out its handshake. An unauthenticated peer could occupy a worker for seconds and serialize every other connection on the mutex. is_headless() now answers from a snapshot refreshed off-thread, and the fresh lookup became a free function called with the manager lock released everywhere; the enforcing decisions, get_username and try_start_x_session, still read seat0 fresh. Also drops seat0_display_server, dead since the fresh-read change. * fix: respect RUSTDESK_FORCED_DISPLAY_SERVER over the greeter correction The greeter correction rewired IS_X11 and is_x11_for_drm() to Wayland whenever seat0 looks like a Wayland greeter, including when the operator explicitly forced the display server: get_display_server() kept honoring the override while the DRM routing gates contradicted it, leaving capture and input routing internally inconsistent. The correction now only adjusts the auto-detected answer. * fix: honest pre-auth snapshot, sticky negative verdict, and a complete forced-x11 gate Four defects found by an adversarial review of the two previous commits, all in their new lines: - The empty-snapshot fallback derived headless from the manager's boot-time seat0 read, which is blank at a Wayland greeter (the loginctl wrapper skips greeter sessions), so the first connection of every server process at a greeter answered headless=true, the opposite of the comment on it. No snapshot now answers NOT headless, the snapshot is seeded at start_xdesktop, and the boot-time cache is gone entirely (it had no reader left). - wait_desktop_cm_ready gated on a bool stored at construction, which can lag one seat0 transition behind and skipped the CM-ready wait right after a logout. It re-reads the snapshot at call time. - A settled Unavailable was erased at NEGATIVE_TTL expiry (state to Unknown, failure counter to zero), so a permanently helper-less box reopened the Unsettled window every 30 seconds and the login decision kept adopting a greeter nothing can serve. The verdict now stays Unavailable while an off-thread re-probe re-verifies it: a failed or empty re-probe restamps the no, and only a non-empty list flips it. - The forced-x11 gate only covered IS_X11 and is_x11_for_drm, while the seat0 adoption path still probed DRM and admitted greeter sessions whose capture and input then routed to X11. Greeter adoption now yields to an operator-forced X11, degrading to upstream behavior: the connection is refused at the login screen. * fix: keep the login request path off the probe entirely try_start_desktop runs while handling a LoginRequest, before password validation, and at a Wayland greeter its seat0 lookup reached the probing availability form: an unauthenticated peer could park a worker for the probe deadline. The greeter adoption now reads a cached tri-state that never blocks; when the state is Unknown it kicks the probe off-thread and answers Unsettled, which the login decision treats as a possibly servable greeter until it settles. Settling lives in the startup warm-up, that kick, and the TTL re-verifiers; the blocking form stays for the capture-side callers, where waiting is acceptable. * fix: run the pre-auth desktop start off the executor, guard the refresh flag, trim comments From fufesou's #15792 re-review (no blocking issues) plus a bot pass: - try_start_desktop now runs on spawn_blocking. It executes loginctl, and PAM when a session must start, while handling a LoginRequest before password validation, so a slow logind must not tie up an async request worker; the blocking pool absorbs it. - kick_seat0_refresh releases SEAT0_REFRESH_IN_FLIGHT through an RAII guard, so a panic in the refresh thread cannot freeze is_headless on a stale snapshot for the process lifetime. - drm_can_serve_login_screen stays Available-only, and the reason is now in the code: it is deliberately not symmetric with the seat0 adoption gate. Adoption yields Xorg only on a definitive Unavailable; admission accepts only on a definitive Available; both wait through an unsettled probe. Admitting there would black-screen a client on a helper-less box, so a review suggestion to make them agree is declined. - Trimmed two over-long comments to the repo's three-line rule. * fix(linux): harden DRM login-screen startup Keep unauthenticated headless checks cache-only, bound OS-session startup to one blocking task, and surface JoinError failures. Wire the isolated Wayland probe consumer and update hbb_common plus libdrmtap 0.5.4. * fix(linux): headless refresh state Signed-off-by: fufesou * fix(linux): keep headless startup state consistent - gate concurrent desktop startup attempts - route CM IPC after refreshing desktop state - avoid blocking seat0 queries in the CM retry loop - preserve newer seat0 snapshots during overlapping refreshes - derive DRM geometry and primary display from one Wayland snapshot Signed-off-by: fufesou --------- Signed-off-by: fufesou Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com> Co-authored-by: rustdesk Co-authored-by: fufesou --- build.py | 4 +- libs/enigo/src/linux/nix_impl.rs | 56 +++++ libs/hbb_common | 2 +- libs/scrap/Cargo.toml | 4 +- src/common.rs | 2 + src/ipc/drm.rs | 5 +- src/platform/linux.rs | 73 ++++++- src/platform/linux_desktop_manager.rs | 266 ++++++++++++++++++---- src/server/connection.rs | 137 +++++++++--- src/server/display_service.rs | 22 ++ src/server/drm_capturer.rs | 304 ++++++++++++++++++++------ src/server/input_service.rs | 15 +- src/server/wayland.rs | 68 +++++- 13 files changed, 808 insertions(+), 150 deletions(-) diff --git a/build.py b/build.py index b32e95672..6b770f993 100755 --- a/build.py +++ b/build.py @@ -390,9 +390,9 @@ def ffi_bindgen_function_refactor(): # The commit is fetched directly by sha, so no branch or tag name takes part in the build: see # build_libdrmtap_so(). This is the SINGLE source of truth for the pin, deliberately not duplicated in # any workflow, so a bump is one edit here (plus the informational version comment in -# libs/scrap/Cargo.toml). This commit is libdrmtap v0.5.2. +# libs/scrap/Cargo.toml). This commit is libdrmtap v0.5.4. LIBDRMTAP_REPO_PINNED = 'https://github.com/rustdesk-org/libdrmtap' -LIBDRMTAP_SHA_PINNED = '653de8c774bc245eaf960611ca7a136f7a544d21' +LIBDRMTAP_SHA_PINNED = '5da68a3a368db569716d0d0f11cefacbb11b2290' LIBDRMTAP_REPO = os.environ.get('DRMTAP_REPO', LIBDRMTAP_REPO_PINNED) LIBDRMTAP_SHA = os.environ.get('DRMTAP_SHA', LIBDRMTAP_SHA_PINNED) # Every way of getting a different .so than the pin needs the same explicit opt-in. Otherwise the diff --git a/libs/enigo/src/linux/nix_impl.rs b/libs/enigo/src/linux/nix_impl.rs index c16be3469..4e379407f 100644 --- a/libs/enigo/src/linux/nix_impl.rs +++ b/libs/enigo/src/linux/nix_impl.rs @@ -42,6 +42,13 @@ impl Enigo { &mut self.custom_mouse } + /// Override the display server guessed in `Default::default`: on "x11" every method here + /// routes to `xdo`, and a null xdo context makes all of them silent no-ops. A caller + /// installing custom devices knows better than the guess. + pub fn set_is_x11(&mut self, is_x11: bool) { + self.is_x11 = is_x11; + } + /// Clear remapped keycodes pub fn tfc_clear_remapped(&mut self) { if let Some(tfc) = &mut self.tfc { @@ -390,3 +397,52 @@ fn test_key_seq() { let mut en = Enigo::new(); en.key_sequence("^^"); } + +/// Both directions: the failure is silent, so a one-directional test passes against the bug. +#[test] +fn test_custom_mouse_dispatch_follows_is_x11() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + struct CountingMouse(Arc); + impl MouseControllable for CountingMouse { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn as_mut_any(&mut self) -> &mut dyn std::any::Any { + self + } + fn mouse_move_to(&mut self, _x: i32, _y: i32) { + self.0.fetch_add(1, Ordering::Relaxed); + } + fn mouse_move_relative(&mut self, _x: i32, _y: i32) {} + fn mouse_down(&mut self, _button: MouseButton) -> crate::ResultType { + Ok(()) + } + fn mouse_up(&mut self, _button: MouseButton) {} + fn mouse_click(&mut self, _button: MouseButton) {} + fn mouse_scroll_x(&mut self, _length: i32) {} + fn mouse_scroll_y(&mut self, _length: i32) {} + } + + let calls = Arc::new(AtomicUsize::new(0)); + let mut en = Enigo::new(); + en.set_custom_mouse(Box::new(CountingMouse(calls.clone()))); + + en.set_is_x11(false); + en.mouse_move_to(10, 20); + assert_eq!( + calls.load(Ordering::Relaxed), + 1, + "custom mouse was not reached on the non-x11 branch" + ); + + // Negative control: on the x11 branch the custom device must be bypassed entirely. + en.set_is_x11(true); + en.mouse_move_to(30, 40); + assert_eq!( + calls.load(Ordering::Relaxed), + 1, + "custom mouse was reached on the x11 branch" + ); +} diff --git a/libs/hbb_common b/libs/hbb_common index 69cea8daf..f124c0a5d 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 69cea8dafee147848ae88702029f4bf7df7224c3 +Subproject commit f124c0a5d49a4a13381902124b65364ff28fa541 diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index da056b46d..bab2b4e9f 100644 --- a/libs/scrap/Cargo.toml +++ b/libs/scrap/Cargo.toml @@ -14,13 +14,13 @@ wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", " # `drm` is a pure runtime-dlopen backend: rustdesk loads `libdrmtap.so.0` at runtime (`drmtap_dl.rs`) # and NEVER link-time links it, so the graceful PipeWire fallback when the .so or EGL is absent is # preserved and the drm build pulls in no libdrm/seccomp/cap/EGL link-time deps. The .so is pinned by -# `DRMTAP_SHA` in build.py, which fetches that exact commit (libdrmtap v0.5.2). We deliberately do +# `DRMTAP_SHA` in build.py, which fetches that exact commit (libdrmtap v0.5.4). We deliberately do # NOT depend on the `libdrmtap-sys` crate: its build.rs statically compiles the whole libdrmtap C tree # and a CAP_SYS_ADMIN helper and emits `-ldrm -lseccomp -lcap`, which would defeat the dlopen model. # Depends on `wayland`: the three drm modules live inside the `#[cfg(feature = "wayland")]` arm of # common/mod.rs, so `scrap/drm` on its own would compile nothing. The root crate happens to always # enable `scrap/wayland`, which is what hid this. -drm = ["wayland"] +drm = ["wayland", "hbb_common/wayland_probe"] mediacodec = ["ndk"] linux-pkg-config = ["dep:pkg-config"] hwcodec = ["dep:hwcodec"] diff --git a/src/common.rs b/src/common.rs index 592ab2a45..09fa1b4ca 100644 --- a/src/common.rs +++ b/src/common.rs @@ -122,6 +122,8 @@ impl Drop for SimpleCallOnReturn { } pub fn global_init() -> bool { + #[cfg(all(target_os = "linux", feature = "drm"))] + crate::platform::linux::dispatch_wayland_display_probe(); #[cfg(target_os = "linux")] { if !crate::platform::linux::is_x11() { diff --git a/src/ipc/drm.rs b/src/ipc/drm.rs index c2c399e6f..15e500e60 100644 --- a/src/ipc/drm.rs +++ b/src/ipc/drm.rs @@ -641,11 +641,12 @@ fn drm_udev_listener() { fn drm_prewarm() { // Re-ask, bounded: `get_display_server()` falls back to "x11" when it cannot tell (measured: - // "x11" 0.8 s into a boot on a Wayland host). `scrap::is_x11()` is the UNMEMOISED path. + // "x11" 0.8 s into a boot on a Wayland host). `is_x11_for_drm()` is that path minus the + // greeter blind spot, which a login screen never leaves. const PREWARM_SESSION_RECHECK: std::time::Duration = std::time::Duration::from_secs(2); const PREWARM_SESSION_BUDGET: std::time::Duration = std::time::Duration::from_secs(30); let waited = std::time::Instant::now(); - while scrap::is_x11() { + while crate::platform::linux::is_x11_for_drm() { if waited.elapsed() >= PREWARM_SESSION_BUDGET { log::info!( "drm: session still reads as X11 after {:?}; skipping the pre-warm \ diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 68a005ff7..f67952e9b 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1,6 +1,15 @@ use super::{gtk_sudo, CursorData, ResultType}; use desktop::Desktop; pub use hbb_common::platform::linux::*; + +#[cfg(feature = "drm")] +pub fn dispatch_wayland_display_probe() { + use std::ffi::OsStr; + + if std::env::args_os().nth(1).as_deref() == Some(OsStr::new(WAYLAND_DISPLAY_PROBE_ARG)) { + wayland_display_probe_child_main(); + } +} use hbb_common::{ allow_err, anyhow::anyhow, @@ -43,8 +52,37 @@ const TERM_XTERM_256COLOR: &str = "xterm-256color"; const TERM_SCREEN_256COLOR: &str = "screen-256color"; const TERM_XTERM: &str = "xterm"; +#[cfg(feature = "drm")] lazy_static::lazy_static! { - pub static ref IS_X11: bool = hbb_common::platform::linux::is_x11_or_headless(); + /// Only for per-frame callers; see `is_login_screen_wayland_cached`. + /// Own block because `#[cfg]` on one item inside a shared one breaks the macro. + static ref IS_LOGIN_SCREEN_WAYLAND: bool = is_login_screen_wayland(); +} + +lazy_static::lazy_static! { + /// `is_x11_or_headless()` answers x11 at a Wayland greeter, which the portal could not + /// serve but the DRM path can. Unmemoised lookup on purpose: this may run mid-boot, and + /// a "no" cached that early would be wrong for the rest of the process. + pub static ref IS_X11: bool = { + let x11 = hbb_common::platform::linux::is_x11_or_headless(); + #[cfg(feature = "drm")] + { + if x11 && !display_server_forced() && is_login_screen_wayland() { + log::info!( + "drm: seat0 is a Wayland login screen that reads as x11 upstream; \ + treating it as Wayland so the DRM path is not disabled at the one \ + screen it exists for" + ); + false + } else { + x11 + } + } + #[cfg(not(feature = "drm"))] + { + x11 + } + }; // Cache for TERM value - once TERM_XTERM_256COLOR is found, reuse it directly static ref CACHED_TERM: std::sync::Mutex> = std::sync::Mutex::new(None); static ref DATABASE_XTERM_256COLOR: Option = { @@ -208,6 +246,34 @@ pub fn is_login_screen_wayland() -> bool { is_gdm_user(&values[1]) && get_display_server_of_session(&values[0]) == DISPLAY_SERVER_WAYLAND } +/// An explicit `RUSTDESK_FORCED_DISPLAY_SERVER` is an operator override, and the root service +/// forwards it to the per-user server on purpose: the greeter correction may only fix an +/// AUTO-detected answer, never argue with the operator — a half-applied override would leave +/// `get_display_server()` and the DRM routing gates disagreeing with each other. +#[cfg(feature = "drm")] +pub(crate) fn display_server_forced() -> bool { + std::env::var("RUSTDESK_FORCED_DISPLAY_SERVER").is_ok() +} + +/// X11 as far as the DRM path is concerned: a Wayland greeter is not, unless the operator +/// forced the display server. +/// +/// Both halves unmemoised, for the retry loops that must keep asking until seat0 can be named. +#[cfg(feature = "drm")] +pub fn is_x11_for_drm() -> bool { + scrap::is_x11() && (display_server_forced() || !is_login_screen_wayland()) +} + +/// Memoised `is_login_screen_wayland`, for per-frame callers that must not run `loginctl`. +/// +/// Only from the per-session `--server`: it is spawned after the session is identified, so the +/// answer is settled. Anything that can run mid-boot must use the uncached form. +#[cfg(feature = "drm")] +#[inline] +pub fn is_login_screen_wayland_cached() -> bool { + *IS_LOGIN_SCREEN_WAYLAND +} + #[inline] fn sleep_millis(millis: u64) { std::thread::sleep(Duration::from_millis(millis)); @@ -1062,6 +1128,11 @@ pub fn get_active_userid() -> String { #[inline] /// Returns the active uid from a fresh seat0 lookup, bypassing the service-loop cache. pub fn get_active_userid_fresh() -> String { + // A Wayland greeter owns seat0 while it is up and the DRM backend serves it, so a uid gate that + // cannot see it rejects the greeter's own `--server`. `Desktop::refresh` reads it the same way. + #[cfg(feature = "drm")] + return get_values_of_seat0_with_gdm_wayland(&[1])[0].clone(); + #[cfg(not(feature = "drm"))] get_values_of_seat0(&[1])[0].clone() } diff --git a/src/platform/linux_desktop_manager.rs b/src/platform/linux_desktop_manager.rs index 4cfde61a2..573dfa018 100644 --- a/src/platform/linux_desktop_manager.rs +++ b/src/platform/linux_desktop_manager.rs @@ -17,22 +17,34 @@ use std::{ path::Path, process::{Child, Command}, sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, mpsc::{sync_channel, SyncSender}, Arc, Mutex, }, time::{Duration, Instant}, }; +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct Seat0Snapshot { + sequence: usize, + username: Option>, +} + lazy_static::lazy_static! { static ref DESKTOP_RUNNING: Arc = Arc::new(AtomicBool::new(false)); static ref DESKTOP_MANAGER: Arc>> = Arc::new(Mutex::new(None)); + /// Last settled "who owns seat0" answer, for the PRE-AUTH path only; see `is_headless`. + static ref SEAT0_SNAPSHOT: Mutex = Mutex::new(Seat0Snapshot::default()); + static ref SEAT0_NEXT_REFRESH: Mutex> = Mutex::new(None); } +static SEAT0_REFRESH_IN_FLIGHT: AtomicBool = AtomicBool::new(false); +const FIRST_SEAT0_QUERY_SEQUENCE: usize = 1; +static SEAT0_QUERY_SEQUENCE: AtomicUsize = AtomicUsize::new(FIRST_SEAT0_QUERY_SEQUENCE); +const SEAT0_REFRESH_INTERVAL: Duration = Duration::from_secs(1); + #[derive(Debug)] struct DesktopManager { - seat0_username: String, - seat0_display_server: String, child_username: String, child_exit: Arc, is_child_running: Arc, @@ -53,6 +65,9 @@ pub fn start_xdesktop() { std::thread::spawn(|| { DesktopManager::recover_orphaned_session(); *DESKTOP_MANAGER.lock().unwrap() = Some(DesktopManager::new()); + // Seed the pre-auth snapshot now, off the connection path: without this the first + // connection of every server process would read no snapshot at all. + kick_seat0_refresh(); let interval = time::Duration::from_millis(super::SERVICE_INTERVAL); DESKTOP_RUNNING.store(true, Ordering::SeqCst); @@ -154,6 +169,7 @@ pub fn try_start_desktop(_username: &str, _passsword: &str) -> String { .to_owned() } else { let username = get_username(); + log::debug!("try_start_desktop, username: {}, _username: {}", &username, &_username); if username == _username { // No need to verify password here. return "".to_owned(); @@ -195,9 +211,12 @@ pub fn try_start_desktop(_username: &str, _passsword: &str) -> String { } fn try_start_x_session(username: &str, password: &str) -> Result<(String, bool), XSessionStartError> { + // Seat0 is read BEFORE the manager lock: the lookup runs loginctl, and at a greeter the DRM + // probe, and holding DESKTOP_MANAGER across those waits serializes every other caller. + let seat0_username = refresh_seat0_snapshot(); let mut desktop_manager = DESKTOP_MANAGER.lock().unwrap(); if let Some(desktop_manager) = &mut (*desktop_manager) { - if let Some(seat0_username) = desktop_manager.get_supported_display_seat0_username() { + if let Some(seat0_username) = seat0_username { return Ok((seat0_username, true)); } @@ -219,27 +238,188 @@ fn try_start_x_session(username: &str, password: &str) -> Result<(String, bool), } #[inline] +/// The PRE-AUTH form: connection setup asks this before the peer has authenticated, so it must +/// not run loginctl or wait on the DRM probe (an unauthenticated client would occupy a worker, +/// and every connection would serialize behind the same lookup). It answers from the last +/// settled snapshot and refreshes it off-thread; the decisions that ENFORCE — `get_username`, +/// `try_start_x_session` — stay fresh. pub fn is_headless() -> bool { - DESKTOP_MANAGER - .lock() - .unwrap() - .as_ref() - .map_or(false, |manager| { - manager.get_supported_display_seat0_username().is_none() + if DESKTOP_MANAGER.lock().unwrap().is_none() { + return false; + } + let cached = SEAT0_SNAPSHOT.lock().unwrap().username.clone(); + kick_seat0_refresh(); + // No snapshot yet answers NOT headless: guessing in the headless direction would show the + // OS-login flow over a live Wayland greeter, which reads as an empty seat0 too. A false + // only delays the headless flow until the first refresh lands, and the snapshot is seeded + // from `start_xdesktop`, so the empty window is server start, not every connection. + cached.map_or(false, |answer| answer.is_none()) +} + +/// A free function on purpose: it runs loginctl (and at a greeter the DRM probe), so no caller +/// may reach it while holding `DESKTOP_MANAGER` — that mutex held across subprocess or IPC waits +/// serializes every connection behind one slow lookup. +fn supported_display_seat0_username() -> Option { + // Read seat0 fresh on every query: the values cached in `DesktopManager::new()` go stale + // across a logout or fast-user-switch, which would skip the greeter probe below and hand + // back the previous session owner. Queried here and not in `new()` also because the read + // there hides greeters. + let seat0_values = get_values_of_seat0(&[0, 2]); + let seat0_username = seat0_values[1].clone(); + #[cfg(feature = "drm")] + if seat0_username.is_empty() || is_gdm_user(&seat0_username) { + if let Some(username) = drm_login_screen_seat0_username() { + return Some(username); + } + } + if seat0_username.is_empty() { + None + } else if is_gdm_user(&seat0_username) + && get_display_server_of_session(&seat0_values[0]) == DISPLAY_SERVER_WAYLAND + { + None + } else { + Some(seat0_username) + } +} + +fn select_newer_seat0_snapshot(current: Seat0Snapshot, candidate: Seat0Snapshot) -> Seat0Snapshot { + if candidate.sequence > current.sequence { + candidate + } else { + current + } +} + +fn refresh_seat0_snapshot() -> Option { + let sequence = SEAT0_QUERY_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let fresh = supported_display_seat0_username(); + let candidate = Seat0Snapshot { + sequence, + username: Some(fresh.clone()), + }; + let mut snapshot = SEAT0_SNAPSHOT.lock().unwrap(); + let current = std::mem::take(&mut *snapshot); + *snapshot = select_newer_seat0_snapshot(current, candidate); + fresh +} + +/// Clears the single-flight flag on every exit, including a panic in the refresh thread; without +/// it a panic would freeze `is_headless` on a stale snapshot for the process lifetime. +struct Seat0RefreshGuard; +impl Drop for Seat0RefreshGuard { + fn drop(&mut self) { + SEAT0_REFRESH_IN_FLIGHT.store(false, Ordering::Release); + } +} + +/// Refresh the snapshot off-thread with a process-wide rate limit and single-flight. +fn kick_seat0_refresh() { + let now = Instant::now(); + { + let mut next_refresh = SEAT0_NEXT_REFRESH.lock().unwrap(); + let (next, should_refresh) = schedule_seat0_refresh(*next_refresh, now); + *next_refresh = next; + if !should_refresh { + return; + } + } + if SEAT0_REFRESH_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return; + } + let guard = Seat0RefreshGuard; + if let Err(err) = std::thread::Builder::new() + .name("seat0-snapshot".into()) + .spawn(move || { + let _guard = guard; + let _ = refresh_seat0_snapshot(); }) + { + log::warn!("Could not spawn the seat0 snapshot refresh thread: {err}"); + } +} + +/// The Wayland greeter on seat0, if the DRM backend can capture and inject into it. +#[cfg(feature = "drm")] +fn drm_login_screen_seat0_username() -> Option { + // An operator-forced X11 wins over greeter adoption: adopting would rebuild exactly the + // inconsistency the forced gate exists to prevent — a session admitted for DRM serving + // while capture and input route down the X11 path. + if crate::platform::linux::display_server_forced() && crate::platform::linux::is_x11() { + return None; + } + let values = get_values_of_seat0_with_gdm_wayland(&[0, 2]); + if !is_gdm_user(&values[1]) + || get_display_server_of_session(&values[0]) != DISPLAY_SERVER_WAYLAND + { + return None; + } + // The cached tri-state, never the probing form: this runs on the unauthenticated login path, + // so it must not wait out a probe deadline. Only a definitive unavailable hands the seat to + // X11; an unsettled result keeps the maybe-live greeter (settling happens off-thread). + if crate::server::drm_capturer::availability_cached() + == crate::server::drm_capturer::Availability::Unavailable + { + return None; + } + Some(values[1].clone()) +} + +fn cached_username_from_state( + seat0_username: Option, + managed_session: Option<(&str, bool)>, +) -> String { + if let Some(username) = seat0_username { + return username; + } + match managed_session { + Some((username, true)) => username.to_owned(), + _ => String::new(), + } +} + +fn schedule_seat0_refresh(next_refresh: Option, now: Instant) -> (Option, bool) { + if next_refresh.is_some_and(|deadline| now < deadline) { + return (next_refresh, false); + } + (Some(now + SEAT0_REFRESH_INTERVAL), true) +} + +/// Returns the last settled username without running external commands. +pub fn get_cached_username() -> String { + let seat0_username = SEAT0_SNAPSHOT.lock().unwrap().username.clone().flatten(); + let username = { + let manager = DESKTOP_MANAGER.lock().unwrap(); + let Some(manager) = manager.as_ref() else { + return String::new(); + }; + cached_username_from_state( + seat0_username, + Some((&manager.child_username, manager.is_running())), + ) + }; + if username.is_empty() { + kick_seat0_refresh(); + } + username } pub fn get_username() -> String { + if DESKTOP_MANAGER.lock().unwrap().is_none() { + return "".to_owned(); + } + // Computed with the manager lock RELEASED: the lookup runs loginctl, and at a greeter the + // DRM probe, and holding DESKTOP_MANAGER across those waits serializes every caller behind + // one slow probe. + if let Some(seat0_username) = refresh_seat0_snapshot() { + return seat0_username; + } match &*DESKTOP_MANAGER.lock().unwrap() { Some(manager) => { - if let Some(seat0_username) = manager.get_supported_display_seat0_username() { - seat0_username + if manager.is_running() && !manager.child_username.is_empty() { + manager.child_username.clone() } else { - if manager.is_running() && !manager.child_username.is_empty() { - manager.child_username.clone() - } else { - "".to_owned() - } + "".to_owned() } } None => "".to_owned(), @@ -258,33 +438,13 @@ impl DesktopManager { } pub fn new() -> Self { - let mut seat0_username = "".to_owned(); - let mut seat0_display_server = "".to_owned(); - let seat0_values = get_values_of_seat0(&[0, 2]); - if !seat0_values[0].is_empty() { - seat0_username = seat0_values[1].clone(); - seat0_display_server = get_display_server_of_session(&seat0_values[0]); - } Self { - seat0_username, - seat0_display_server, child_username: "".to_owned(), child_exit: Arc::new(AtomicBool::new(true)), is_child_running: Arc::new(AtomicBool::new(false)), } } - fn get_supported_display_seat0_username(&self) -> Option { - if is_gdm_user(&self.seat0_username) && self.seat0_display_server == DISPLAY_SERVER_WAYLAND - { - None - } else if self.seat0_username.is_empty() { - None - } else { - Some(self.seat0_username.clone()) - } - } - #[inline] fn get_xauth() -> String { let xauth = get_env_var("XAUTHORITY"); @@ -1100,6 +1260,38 @@ fn pam_get_service_name() -> String { mod tests { use super::*; + #[test] + fn cached_username_prefers_seat0_and_running_managed_session() { + assert_eq!( + cached_username_from_state(Some("seat0".to_owned()), Some(("managed", true))), + "seat0" + ); + assert_eq!( + cached_username_from_state(None, Some(("managed", true))), + "managed" + ); + assert_eq!( + cached_username_from_state(None, Some(("managed", false))), + "" + ); + assert_eq!(cached_username_from_state(None, None), ""); + } + + #[test] + fn seat0_refresh_schedule_limits_process_wide_rate() { + let started = Instant::now(); + let (next_refresh, should_refresh) = schedule_seat0_refresh(None, started); + assert!(should_refresh); + + let (unchanged, should_refresh) = schedule_seat0_refresh(next_refresh, started); + assert!(!should_refresh); + assert_eq!(unchanged, next_refresh); + + let (_, should_refresh) = + schedule_seat0_refresh(next_refresh, started + SEAT0_REFRESH_INTERVAL); + assert!(should_refresh); + } + #[test] fn session_scope_truncates_at_first_scope() { assert_eq!( diff --git a/src/server/connection.rs b/src/server/connection.rs index b461a3eff..bf05d56bd 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -122,9 +122,21 @@ fn should_check_linux_headless_os_auth_before_desktop_start( is_headless_allowed: bool, username: &str, ) -> bool { - is_headless_allowed - && !username.trim().is_empty() - && linux_desktop_manager::get_username().is_empty() + is_headless_allowed && !username.trim().is_empty() +} + +#[cfg(target_os = "linux")] +fn linux_desktop_start_credentials( + is_headless_allowed: bool, + os_login: Option<&OSLogin>, +) -> Option<(String, String)> { + if !is_headless_allowed { + return None; + } + if let Some(os_login) = os_login.filter(|os_login| !os_login.username.trim().is_empty()) { + return Some((os_login.username.clone(), os_login.password.clone())); + } + Some((String::new(), String::new())) } #[cfg(target_os = "linux")] @@ -464,6 +476,24 @@ const SEND_TIMEOUT_VIDEO: u64 = 12_000; const SEND_TIMEOUT_OTHER: u64 = SEND_TIMEOUT_VIDEO * 10; const SESSION_TIMEOUT: Duration = Duration::from_secs(30); +/// Whether the DRM backend can serve a Wayland login screen here. +/// +/// The cached probe, not the blocking one: this is a routing gate. Available-only ON PURPOSE, and +/// deliberately NOT symmetric with the seat0 adoption gate: that one only starts Xorg on a +/// definitive Unavailable (never over a maybe-live greeter), while admission only accepts on a +/// definitive Available (never a greeter nothing can yet capture). Both err toward refuse-and-retry +/// during an unsettled probe; admitting there would black-screen a client on a helper-less box. +#[cfg(all(target_os = "linux", feature = "drm"))] +fn drm_can_serve_login_screen() -> bool { + super::drm_capturer::is_available_cached() +} + +/// Without the feature nothing can capture a Wayland greeter, so the refusal stands. +#[cfg(all(target_os = "linux", not(feature = "drm")))] +fn drm_can_serve_login_screen() -> bool { + false +} + impl Connection { pub async fn start( addr: SocketAddr, @@ -1967,7 +1997,8 @@ impl Connection { #[cfg(target_os = "linux")] if self.is_remote() { let mut msg = "".to_string(); - if crate::platform::linux::is_login_screen_wayland() { + // Refuse only while nothing can capture a Wayland greeter: the DRM path can. + if crate::platform::linux::is_login_screen_wayland() && !drm_can_serve_login_screen() { msg = crate::client::LOGIN_SCREEN_WAYLAND.to_owned() } else { let dtype = crate::platform::linux::get_display_server(); @@ -2883,6 +2914,7 @@ impl Connection { #[cfg(not(any(target_os = "android", target_os = "ios")))] if !should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) { + #[cfg(not(target_os = "linux"))] self.try_start_cm_ipc(); } @@ -2900,9 +2932,18 @@ impl Connection { #[cfg(not(target_os = "linux"))] let err_msg = "".to_owned(); #[cfg(target_os = "linux")] - let err_msg = self + let err_msg = match self .linux_headless_handle - .try_start_desktop(lr.os_login.as_ref()); + .try_start_desktop(lr.os_login.as_ref()) + .await + { + LinuxDesktopStartOutcome::Finished(err_msg) => err_msg, + LinuxDesktopStartOutcome::Busy => { + self.send_login_error(crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY) + .await; + return true; + } + }; // If err is LOGIN_MSG_DESKTOP_SESSION_NOT_READY, just keep this msg and go on checking password. if !err_msg.is_empty() && err_msg != crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY @@ -2923,6 +2964,12 @@ impl Connection { return true; } + #[cfg(target_os = "linux")] + if !should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) { + // In headless mode, the desktop check above settles the snapshot used by CM routing. + self.try_start_cm_ipc(); + } + // https://github.com/rustdesk/rustdesk-server-pro/discussions/646 // `is_logon` is used to check login with `OPTION_ALLOW_LOGON_SCREEN_PASSWORD` == "Y". // `is_logon_ui()` is a fallback for logon UI detection on Windows. @@ -6221,20 +6268,20 @@ async fn start_ipc( // Cm run as user, wait until desktop session is ready. #[cfg(target_os = "linux")] if headless_cm { - let mut username = linux_desktop_manager::get_username(); + let mut username = linux_desktop_manager::get_cached_username(); loop { if !username.is_empty() { break; } // `_rx_desktop_ready` is used as a wake-up signal from desktop/session state changes // (for example wait_desktop_cm_ready paths). It is not itself a proof of CM readiness. - // TODO: - // When `_rx_desktop_ready` is closed, `recv()` returns - // `None` immediately and this loop may spin if `username` remains empty. - // Keep behavior unchanged for now; if field reports appear, handle `Ok(None)` by - // breaking/returning to avoid hot-looping. - let _res = timeout(1_000, _rx_desktop_ready.recv()).await; - username = linux_desktop_manager::get_username(); + let wait_result = timeout(1_000, _rx_desktop_ready.recv()).await; + if matches!(wait_result, Ok(None)) { + return Err(anyhow!( + "Desktop-ready channel closed before a Linux session became available" + )); + } + username = linux_desktop_manager::get_cached_username(); } let uid = { let username_for_cmd = username.clone(); @@ -6652,10 +6699,30 @@ impl Drop for Connection { } } +// Login requests are unauthenticated here, so only one may reach loginctl/PAM at a time. +#[cfg(target_os = "linux")] +static LINUX_DESKTOP_START_IN_FLIGHT: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +#[cfg(target_os = "linux")] +struct LinuxDesktopStartGuard; + +#[cfg(target_os = "linux")] +impl Drop for LinuxDesktopStartGuard { + fn drop(&mut self) { + LINUX_DESKTOP_START_IN_FLIGHT.store(false, Ordering::Release); + } +} + +#[cfg(target_os = "linux")] +enum LinuxDesktopStartOutcome { + Finished(String), + Busy, +} + #[cfg(target_os = "linux")] struct LinuxHeadlessHandle { pub is_headless_allowed: bool, - pub is_headless: bool, pub wait_ipc_timeout: u64, pub rx_cm_stream_ready: mpsc::Receiver<()>, pub tx_desktop_ready: mpsc::Sender<()>, @@ -6665,31 +6732,45 @@ struct LinuxHeadlessHandle { impl LinuxHeadlessHandle { pub fn new(rx_cm_stream_ready: mpsc::Receiver<()>, tx_desktop_ready: mpsc::Sender<()>) -> Self { let is_headless_allowed = crate::is_server() && crate::platform::is_headless_allowed(); - let is_headless = is_headless_allowed && linux_desktop_manager::is_headless(); Self { is_headless_allowed, - is_headless, wait_ipc_timeout: 10_000, rx_cm_stream_ready, tx_desktop_ready, } } - pub fn try_start_desktop(&mut self, os_login: Option<&OSLogin>) -> String { - if self.is_headless_allowed { - match os_login { - Some(os_login) => { - linux_desktop_manager::try_start_desktop(&os_login.username, &os_login.password) - } - None => linux_desktop_manager::try_start_desktop("", ""), - } - } else { - "".to_string() + pub async fn try_start_desktop( + &mut self, + os_login: Option<&OSLogin>, + ) -> LinuxDesktopStartOutcome { + let Some((username, password)) = + linux_desktop_start_credentials(self.is_headless_allowed, os_login) + else { + return LinuxDesktopStartOutcome::Finished(String::new()); + }; + if LINUX_DESKTOP_START_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return LinuxDesktopStartOutcome::Busy; } + let guard = LinuxDesktopStartGuard; + let err_msg = match tokio::task::spawn_blocking(move || { + let _guard = guard; + linux_desktop_manager::try_start_desktop(&username, &password) + }) + .await + { + Ok(err_msg) => err_msg, + Err(err) => { + log::error!("Linux desktop start task failed: {err}"); + crate::client::LOGIN_MSG_DESKTOP_XSESSION_FAILED.to_owned() + } + }; + LinuxDesktopStartOutcome::Finished(err_msg) } pub async fn wait_desktop_cm_ready(&mut self) { - if self.is_headless { + // A value captured at construction can lag behind a seat0 transition. + if self.is_headless_allowed && linux_desktop_manager::is_headless() { self.tx_desktop_ready.send(()).await.ok(); let _res = timeout(self.wait_ipc_timeout, self.rx_cm_stream_ready.recv()).await; } diff --git a/src/server/display_service.rs b/src/server/display_service.rs index 3647d7ee6..7572caf10 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -100,6 +100,11 @@ fn refresh_wayland_uinput_rect_if_changed() { if is_x11() || !crate::input_service::wayland_use_uinput() { return; } + // Nothing to poll at a login screen; the DRM path owns the rect there. + #[cfg(feature = "drm")] + if crate::platform::linux::is_login_screen_wayland_cached() { + return; + } { let mut lock = WAYLAND_UINPUT_RECT.lock().unwrap(); if let Some(last_check) = lock.last_check { @@ -484,6 +489,22 @@ pub(super) fn check_update_displays(all: &Vec) { let _ = update_sync_displays(all); } +/// Whether there is a compositor on this seat worth asking. `get_displays()` does not cache +/// its failure, so where there is none it re-probes every call for an answer that cannot +/// change any caller's outcome. Last in the `&&` chain, so it never runs first on a poll. +#[inline] +#[cfg(target_os = "linux")] +fn wayland_has_compositor() -> bool { + #[cfg(feature = "drm")] + { + !crate::platform::linux::is_login_screen_wayland_cached() + } + #[cfg(not(feature = "drm"))] + { + true + } +} + // Return the converted input snapshot while updating the shared display cache. pub(super) fn update_sync_displays(all: &Vec) -> Vec { // For compatibility: if only one display, scale remains 1.0 and we use the physical size for `uinput`. @@ -491,6 +512,7 @@ pub(super) fn update_sync_displays(all: &Vec) -> Vec { #[cfg(target_os = "linux")] let use_logical_scale = !is_x11() && crate::is_server() + && wayland_has_compositor() && scrap::wayland::display::get_displays().displays.len() > 1; let displays = all .iter() diff --git a/src/server/drm_capturer.rs b/src/server/drm_capturer.rs index d447715df..0c6beb493 100644 --- a/src/server/drm_capturer.rs +++ b/src/server/drm_capturer.rs @@ -823,40 +823,110 @@ impl Drop for UinputRefreshGuard { /// Never probes, never blocks: the form the ROUTING gates must use. Seconds of IPC inside /// `wayland::clear()`, `is_inited()` or the display enumeration trips "deadline has elapsed". -pub(super) fn is_available_cached() -> bool { +pub(crate) fn is_available_cached() -> bool { matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)) } -/// MAY BLOCK for seconds: never a routing gate. -pub(super) fn is_available() -> bool { - let verdict = { - let mut st = DRM_STATE.lock().unwrap(); - if let ProbeState::Unavailable(since) = &*st { - if since.elapsed() >= NEGATIVE_TTL { - publish_probe_state(&mut st, ProbeState::Unknown); - DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); +/// The three honest answers the availability machinery can give. `Unsettled` — another probe in +/// flight, or a failure still below the disable threshold — is not a verdict, and the +/// login-screen headless decision must not read it as one. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Availability { + Available, + Unavailable, + Unsettled, +} + +/// MAY BLOCK for seconds: never a routing gate, and never on the login request path — that path +/// reads `availability_cached`. This blocking form serves the capture-side callers through +/// `is_available`, where waiting out a settle is acceptable. +fn availability() -> Availability { + let (verdict, stale_no) = { + let st = DRM_STATE.lock().unwrap(); + // A settled "no" STAYS the answer while an off-thread re-probe re-verifies it; going + // Unknown at expiry would reopen an Unsettled window every TTL on a helper-less box, and + // the login decision reads Unsettled as a possible greeter. + let stale_no = + matches!(&*st, ProbeState::Unavailable(since) if since.elapsed() >= NEGATIVE_TTL); + let verdict = match &*st { + ProbeState::Available(since, _) => { + Some((Availability::Available, since.elapsed() >= POSITIVE_TTL)) } - } - match &*st { - ProbeState::Available(since, _) => Some((true, since.elapsed() >= POSITIVE_TTL)), - ProbeState::Unavailable(_) => Some((false, false)), + ProbeState::Unavailable(_) => Some((Availability::Unavailable, false)), ProbeState::Unknown => None, // fall through and probe with the lock released - } + }; + (verdict, stale_no) }; - if let Some((available, stale)) = verdict { + if let Some((answer, stale)) = verdict { if stale { refresh_available_async(); } - return available; + if stale_no { + refresh_unavailable_async(); + } + return answer; } if DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { - return matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)); + // Someone else is mid-probe: their result is not in yet, and "not yet" is not "no". + return match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(..) => Availability::Available, + ProbeState::Unavailable(_) => Availability::Unavailable, + ProbeState::Unknown => Availability::Unsettled, + }; } let _in_flight = ProbeInFlightGuard; + probe_and_publish() +} + +/// The non-blocking tri-state, for decisions on the LOGIN REQUEST path that must never wait: an +/// unauthenticated peer reaches that path, so a probe there would let it park a worker for the +/// probe deadline. Unknown kicks the probe off-thread and answers Unsettled, which the login +/// decision treats as a possibly servable greeter (no Xorg) until the state settles. +pub(crate) fn availability_cached() -> Availability { + let (verdict, stale_no) = { + let st = DRM_STATE.lock().unwrap(); + let stale_no = + matches!(&*st, ProbeState::Unavailable(since) if since.elapsed() >= NEGATIVE_TTL); + let verdict = match &*st { + ProbeState::Available(since, _) => { + Some((Availability::Available, since.elapsed() >= POSITIVE_TTL)) + } + ProbeState::Unavailable(_) => Some((Availability::Unavailable, false)), + ProbeState::Unknown => None, + }; + (verdict, stale_no) + }; + if let Some((answer, stale)) = verdict { + if stale { + refresh_available_async(); + } + if stale_no { + refresh_unavailable_async(); + } + return answer; + } + if !DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + let in_flight = ProbeInFlightGuard; + let spawned = std::thread::Builder::new() + .name("drm-avail-probe".into()) + .spawn(move || { + let _in_flight = in_flight; + probe_and_publish(); + }); + // On error the guard moved into the dropped closure and released the flag already. + if let Err(err) = spawned { + log::warn!("drm: could not spawn the availability probe thread: {err}"); + } + } + Availability::Unsettled +} + +/// Probe synchronously and publish the outcome. The caller must hold DRM_PROBE_IN_FLIGHT. +fn probe_and_publish() -> Availability { let t = Instant::now(); let result = query_displays(); let mut st = DRM_STATE.lock().unwrap(); - let available = match result { + let answer = match result { Ok(list) if !list.is_empty() => { log::debug!( "drm: availability probe -> available ({} displays) in {:?}", @@ -865,28 +935,84 @@ pub(super) fn is_available() -> bool { ); DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); - true + Availability::Available } Ok(_) => { log::info!("drm: availability probe -> no displays in {:?}", t.elapsed()); publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); - false + Availability::Unavailable } Err(err) => { let n = DRM_PROBE_FAILURES.fetch_add(1, Ordering::Relaxed) + 1; if n >= DRM_PROBE_MAX_FAILURES { log::info!("drm: availability probe failed {n}x ({err}); disabling DRM"); publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + Availability::Unavailable } else { log::info!( "drm: availability probe failed ({err}), attempt {n}/{DRM_PROBE_MAX_FAILURES}; will retry" ); + // Deliberately still Unknown in DRM_STATE: this is a retry window, not a verdict. + Availability::Unsettled } - false } }; drop(st); - available + answer +} + +/// The boolean form for capture-path callers, where an unsettled probe and a definitive "no" +/// route the same way (into the non-DRM fallback). +pub(crate) fn is_available() -> bool { + availability() == Availability::Available +} + +/// The negative mirror of `refresh_available_async`: re-verify a stale Unavailable without ever +/// answering Unknown in the meantime. A failed or empty re-probe re-confirms the "no" with a +/// fresh timestamp; only a non-empty display list flips the verdict. +fn refresh_unavailable_async() { + if DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return; + } + let in_flight = ProbeInFlightGuard; + let sampled_gen = { + let st = DRM_STATE.lock().unwrap(); + match &*st { + ProbeState::Unavailable(since) if since.elapsed() >= NEGATIVE_TTL => {} + _ => return, + } + DRM_STATE_GEN.load(Ordering::Acquire) + }; + let spawned = std::thread::Builder::new() + .name("drm-unavail-refresh".into()) + .spawn(move || { + let _in_flight = in_flight; + let result = query_displays(); + let mut st = DRM_STATE.lock().unwrap(); + if DRM_STATE_GEN.load(Ordering::Acquire) != sampled_gen { + return; + } + match result { + Ok(list) if !list.is_empty() => { + log::info!( + "drm: availability re-probe -> available ({} displays)", + list.len() + ); + DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); + drop(st); + scrap::wayland::display::clear_wayland_displays_cache(); + } + _ => { + // Restamp: a failed or empty re-probe is a fresh confirmation of "no". + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + } + } + }); + // Nothing to release on error: the guard moved into the closure and drops with it either way. + if let Err(err) = spawned { + log::warn!("drm: could not spawn the unavailability re-probe thread: {err}"); + } } fn refresh_available_async() { @@ -963,9 +1089,10 @@ fn refresh_available_async() { pub(super) fn warm_availability() { // The gate is INSIDE the loop because `get_display_server()` answers "x11" whenever loginctl - // cannot yet name the seat0 session. `scrap::is_x11()` is the UNMEMOISED form. + // cannot yet name the seat0 session. `is_x11_for_drm()` is that form minus the greeter + // blind spot, where plain `is_x11()` is permanently true. for _ in 0..10 { - if scrap::is_x11() { + if crate::platform::linux::is_x11_for_drm() { std::thread::sleep(Duration::from_millis(300)); continue; } @@ -1059,64 +1186,99 @@ pub(super) fn display_count_and_any_demoted() -> Option<(usize, bool)> { Some((len, any_demoted)) } -/// Releases DRM_STATE before taking the health map: never hold it while taking a per-display map. +// A multi-display portal stream cannot replace one demoted connector. Keep its index but mark it +// offline; a single connector remains usable through the whole-desktop fallback. +fn mark_demoted_displays(list: &[DrmDisplayInfo], infos: &mut [DisplayInfo]) { + if list.len() <= 1 { + return; + } + let health = DRM_DISPLAY_HEALTH.lock().unwrap(); + for (display, info) in list.iter().zip(infos.iter_mut()) { + if health + .get(&connector_key(display)) + .is_some_and(|health| health.demoted()) + { + info.online = false; + } + } +} + +fn primary_index_from_assignment(assignment: &[Option], primary: usize) -> usize { + assignment + .iter() + .position(|assigned| *assigned == Some(primary)) + .unwrap_or(0) +} + +/// Releases DRM_STATE before taking the Wayland and health locks. +pub(super) fn get_display_infos_and_primary() -> Option<(Vec, usize)> { + let list = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => list.clone(), + _ => return None, + }; + let wl = scrap::wayland::display::get_displays(); + let assignment = assign_wayland_outputs(&list, &wl.displays); + let mut infos = augment_with_wayland_geometry_from(&list, &wl, &assignment); + mark_demoted_displays(&list, &mut infos); + // Primary and geometry must use the same connector assignment snapshot. + let primary = primary_index_from_assignment(&assignment, wl.primary); + Some((infos, primary)) +} + pub(super) fn get_display_infos() -> Option> { let list = match &*DRM_STATE.lock().unwrap() { ProbeState::Available(_, list) => list.clone(), _ => return None, }; - let multi = list.len() > 1; let mut infos = augment_with_wayland_geometry(&list); - // The portal exposes one whole-desktop stream, so a demoted display on a multi-monitor host - // has nothing geometry-consistent to fall back to: OFFLINE but KEEPING its list position, so - // the index space stays aligned with get_capturer_info(). A single-display host stays online. - if multi { - let health = DRM_DISPLAY_HEALTH.lock().unwrap(); - for (idx, info) in infos.iter_mut().enumerate() { - let key = match list.get(idx) { - Some(d) => connector_key(d), - None => continue, - }; - if health.get(&key).is_some_and(|h| h.demoted()) { - info.online = false; - } - } - } + mark_demoted_displays(&list, &mut infos); Some(infos) } -/// Index of the compositor's PRIMARY output; 0 when unknown. Asking `assign_wayland_outputs` makes -/// the advertised primary and geometry agree, but not below two connectors or two outputs, where -/// `augment_with_wayland_geometry` declines to run the assignment. -pub(super) fn get_primary_index() -> usize { - let list = match &*DRM_STATE.lock().unwrap() { - ProbeState::Available(_, list) => list.clone(), - _ => return 0, - }; - let wl = scrap::wayland::display::get_displays(); - if wl.displays.is_empty() { - return 0; - } - assign_wayland_outputs(&list, &wl.displays) - .iter() - .position(|assigned| *assigned == Some(wl.primary)) - .unwrap_or(0) -} - /// DRM reports every monitor at physical size and origin (0,0), stacking a multi-monitor client. +/// +/// Asked at login screens too, on purpose: a greeter runs a compositor, and the socket fallback in +/// hbb_common lets the enumerator reach it with no environment variables. Where that fallback +/// cannot answer, the list comes back empty and everything stays unaugmented, which is what the +/// old is-login-screen gate produced unconditionally. fn augment_with_wayland_geometry(drm: &[DrmDisplayInfo]) -> Vec { let wl = scrap::wayland::display::get_displays(); + let assignment = assign_wayland_outputs(drm, &wl.displays); + augment_with_wayland_geometry_from(drm, &wl, &assignment) +} + +fn augment_with_wayland_geometry_from( + drm: &[DrmDisplayInfo], + wl: &scrap::wayland::display::Displays, + matched: &[Option], +) -> Vec { let mut infos: Vec = drm.iter().map(display_info_from_drm).collect(); - if drm.len() < 2 || wl.displays.len() < 2 { + // A single display is still augmented: on a multi-GPU host the one connector this service can + // open may sit at a non-zero origin in the compositor layout, and DRM alone reports (0,0). + if drm.is_empty() { + return infos; + } + if wl.displays.is_empty() { + return infos; + } + // One connector against one output is the origin-only case: the lone output can still sit at + // a non-zero origin this side cannot see, but it keeps the scale-1 convention — a single + // display is advertised at physical size (see `logical_rects_of`), so its logical size must + // not be adopted. More connectors than the one output is an inconsistent snapshot, and the + // layout-order fallback in `assign_wayland_outputs` would plant that origin on a guess. + let origin_only = wl.displays.len() == 1; + if origin_only && drm.len() > 1 { return infos; } - let matched = assign_wayland_outputs(drm, &wl.displays); for (i, info) in infos.iter_mut().enumerate() { let Some(w) = matched[i].map(|j| &wl.displays[j]) else { continue; }; info.x = w.x; info.y = w.y; + if origin_only { + continue; + } if let Some((lw, lh)) = w.logical_size { if lw > 0 && lh > 0 { info.scale = drm[i].width as f64 / lw as f64; @@ -1487,6 +1649,26 @@ mod drm_capturer_tests { } } + #[test] + fn one_connector_assignment_drives_geometry_and_primary() { + let drm = [ + drm_display("HDMI-A-1", 1920, 1080), + drm_display("DP-1", 2560, 1440), + ]; + let wl = scrap::wayland::display::Displays { + primary: 0, + displays: vec![ + wl_display("DP-1", 1920, 0, 2560, 1440), + wl_display("HDMI-1", 0, 0, 1920, 1080), + ], + }; + + let assignment = assign_wayland_outputs(&drm, &wl.displays); + let infos = augment_with_wayland_geometry_from(&drm, &wl, &assignment); + assert_eq!((infos[0].x, infos[1].x), (0, 1920)); + assert_eq!(primary_index_from_assignment(&assignment, wl.primary), 1); + } + #[test] fn frame_buffers_circulate_instead_of_being_reallocated() { let mut c = capturer_with(Some((64, 32))); diff --git a/src/server/input_service.rs b/src/server/input_service.rs index aa6893f39..f8f943276 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -663,17 +663,22 @@ pub async fn setup_uinput(minx: i32, maxx: i32, miny: i32, maxy: i32) -> ResultT let mouse = super::uinput::client::UInputMouse::new().await?; log::info!("UInput mouse created"); - ENIGO - .lock() - .unwrap() - .set_custom_keyboard(Box::new(keyboard)); - ENIGO.lock().unwrap().set_custom_mouse(Box::new(mouse)); + let mut en = ENIGO.lock().unwrap(); + // enigo guessed x11 once at construction, which is what a Wayland greeter reads as, and + // then routes the devices installed below to a null xdo that drops everything silently. + // Reaching here means `wayland_use_uinput()` was true, so this states a fact. + en.set_is_x11(false); + // One lock for both, so there is no window where the keyboard is custom and the mouse is not. + en.set_custom_keyboard(Box::new(keyboard)); + en.set_custom_mouse(Box::new(mouse)); Ok(()) } #[cfg(target_os = "linux")] pub async fn setup_rdp_input() -> ResultType<(), Box> { let mut en = ENIGO.lock()?; + // Same as `setup_uinput`: the caller is gated on `wayland_use_rdp_input()`. + en.set_is_x11(false); let rdp_info_lock = RDP_SESSION_INFO.lock()?; let rdp_info = rdp_info_lock.as_ref().ok_or("RDP session is None")?; diff --git a/src/server/wayland.rs b/src/server/wayland.rs index ffdf12c98..023e9e559 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -107,6 +107,25 @@ struct CapDisplayInfo { capturer: CapturerPtr, } +/// Uinput desktop rect from the DRM display list, for a login screen where no compositor can be +/// asked. `(minx, maxx, miny, maxy)`, in scanout pixels: no compositor here applied a scale, so +/// unlike `desktop_rect_of` there is no logical size to handle. +#[cfg(feature = "drm")] +fn drm_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { + let displays = super::drm_capturer::get_display_infos()?; + if displays.is_empty() { + return None; + } + let minx = displays.iter().map(|d| d.x).min()?; + let miny = displays.iter().map(|d| d.y).min()?; + let maxx = displays.iter().map(|d| d.x + d.width).max()?; + let maxy = displays.iter().map(|d| d.y + d.height).max()?; + if maxx <= minx || maxy <= miny { + return None; + } + Some((minx, maxx, miny, maxy)) +} + /// Set the uinput absolute-pointer range to the whole logical desktop so the compositor maps /// injected coordinates 1:1 instead of stretching a single-monitor range across all outputs. The /// PipeWire path does this inline in `check_init`; the DRM path bypasses check_init so it must do it @@ -134,17 +153,41 @@ pub(super) async fn update_uinput_resolution() { if !crate::input_service::wayland_use_uinput() { return; } - scrap::wayland::display::clear_wayland_displays_cache(); - let Some(rect) = scrap::wayland::display::get_desktop_rect_for_uinput() else { - log::warn!("Failed to get desktop rect for uinput"); - return; + // Compositor first at a login screen too: a greeter runs one, and the hbb_common socket + // fallback reaches it with no environment variables. The DRM union is the fallback, and it is + // a real loss to land there on a multi-monitor host: DRM has no origins, so its union rect + // mis-maps the pointer whenever the compositor arranged the outputs side by side. + // + // Off the executor: the compositor query can block for the socket probe deadline, and this + // runs on current-thread runtimes (session init and the hotplug worker). The layout baseline + // is computed in the SAME task: a failed lookup is not cached, so asking for the rects + // afterwards would rerun the whole socket probe synchronously. + let (rect, layout) = match hbb_common::tokio::task::spawn_blocking(|| { + scrap::wayland::display::clear_wayland_displays_cache(); + match scrap::wayland::display::get_desktop_rect_for_uinput() { + // The lookup above just cached the displays, so the rects come from that snapshot. + Some(rect) => Some((rect, scrap::wayland::display::get_display_rects_for_uinput())), + // Raw DRM union: there is no compositor layout to baseline. Empty keeps the #15601 + // remap inactive, which is right when the origins are unknown anyway. + None => drm_desktop_rect_for_uinput().map(|rect| (rect, Vec::new())), + } + }) + .await + { + Ok(Some(pair)) => pair, + Ok(None) => { + log::warn!("Failed to get desktop rect for uinput"); + return; + } + Err(err) => { + log::warn!("The desktop rect probe task failed: {err}"); + return; + } }; // Re-snapshot the baseline on every call: this runs at session init and after every hotplug, and // the baseline is what the client's coordinates are measured against. let snapshot_layout = || { - super::display_service::set_wayland_layout_baseline( - scrap::wayland::display::get_display_rects_for_uinput(), - ); + super::display_service::set_wayland_layout_baseline(layout.clone()); }; // Reprogram the device only when the range actually changes. A display stuck in a rebuild loop // calls this about once a second, and reapplying an identical range is an IPC roundtrip plus a @@ -331,10 +374,13 @@ pub(super) async fn get_displays_and_primary() -> ResultType<(Vec, // client had already been given. Properly async, so the executor is never blocked; on any // failure the cache serves as before. super::drm_capturer::refresh_displays_for_login().await; - if let Some(displays) = super::drm_capturer::get_display_infos() { - // DRM connector order is not the compositor's primary; resolve the real primary from - // the compositor layout (matched by normalized connector name), not a hardcoded index 0. - return Ok((displays, super::drm_capturer::get_primary_index())); + let snapshot = hbb_common::tokio::task::spawn_blocking( + super::drm_capturer::get_display_infos_and_primary, + ) + .await + .map_err(|err| anyhow::anyhow!("Wayland display probe task failed: {err}"))?; + if let Some(snapshot) = snapshot { + return Ok(snapshot); } } check_init().await?; From d1da05c4dbf3e4f3f87c89da3ddfb2614164a3b3 Mon Sep 17 00:00:00 2001 From: fufesou Date: Fri, 14 Aug 2026 14:31:13 +0800 Subject: [PATCH 24/72] refact: remove feature plugin-framework (#15854) * refact: remove feature plugin-framework Signed-off-by: fufesou * refact: remove unused translations Signed-off-by: fufesou * fix: delete settings tab observable with correct type Signed-off-by: fufesou --------- Signed-off-by: fufesou --- Cargo.lock | 100 +-- Cargo.toml | 4 +- .../lib/desktop/pages/desktop_home_page.dart | 17 - .../desktop/pages/desktop_setting_page.dart | 59 +- flutter/lib/desktop/pages/remote_page.dart | 1 - .../lib/desktop/pages/view_camera_page.dart | 1 - .../lib/desktop/widgets/remote_toolbar.dart | 14 +- flutter/lib/main.dart | 11 - flutter/lib/models/model.dart | 12 - flutter/lib/plugin/common.dart | 42 -- flutter/lib/plugin/event.dart | 18 - flutter/lib/plugin/handlers.dart | 79 --- flutter/lib/plugin/manager.dart | 319 --------- flutter/lib/plugin/model.dart | 110 --- flutter/lib/plugin/ui_manager.dart | 17 - flutter/lib/plugin/utils/dialogs.dart | 86 --- flutter/lib/plugin/widgets/desc_ui.dart | 301 -------- .../lib/plugin/widgets/desktop_settings.dart | 202 ------ flutter/lib/web/bridge.dart | 72 -- flutter/lib/web/plugin/handlers.dart | 14 - src/client/io_loop.rs | 28 - src/core_main.rs | 36 - src/flutter.rs | 43 -- src/flutter_ffi.rs | 177 ----- src/ipc.rs | 9 - src/lang/ar.rs | 3 - src/lang/be.rs | 3 - src/lang/bg.rs | 3 - src/lang/ca.rs | 3 - src/lang/cn.rs | 3 - src/lang/cs.rs | 3 - src/lang/da.rs | 3 - src/lang/de.rs | 3 - src/lang/el.rs | 3 - src/lang/eo.rs | 3 - src/lang/es.rs | 3 - src/lang/et.rs | 3 - src/lang/eu.rs | 3 - src/lang/fa.rs | 3 - src/lang/fi.rs | 3 - src/lang/fr.rs | 3 - src/lang/ge.rs | 3 - src/lang/gu.rs | 3 - src/lang/he.rs | 3 - src/lang/hi.rs | 3 - src/lang/hr.rs | 3 - src/lang/hu.rs | 3 - src/lang/id.rs | 3 - src/lang/it.rs | 3 - src/lang/ja.rs | 3 - src/lang/ko.rs | 3 - src/lang/kz.rs | 3 - src/lang/lt.rs | 3 - src/lang/lv.rs | 3 - src/lang/ml.rs | 3 - src/lang/nb.rs | 3 - src/lang/nl.rs | 3 - src/lang/pl.rs | 3 - src/lang/pt_PT.rs | 3 - src/lang/ptbr.rs | 3 - src/lang/ro.rs | 3 - src/lang/ru.rs | 3 - src/lang/sc.rs | 3 - src/lang/sk.rs | 3 - src/lang/sl.rs | 3 - src/lang/sq.rs | 3 - src/lang/sr.rs | 3 - src/lang/sv.rs | 3 - src/lang/ta.rs | 3 - src/lang/template.rs | 3 - src/lang/th.rs | 3 - src/lang/tr.rs | 3 - src/lang/tw.rs | 3 - src/lang/uk.rs | 3 - src/lang/vi.rs | 3 - src/lib.rs | 4 - src/plugin/callback_ext.rs | 44 -- src/plugin/callback_msg.rs | 411 ----------- src/plugin/config.rs | 363 ---------- src/plugin/desc.rs | 100 --- src/plugin/errno.rs | 50 -- src/plugin/ipc.rs | 230 ------ src/plugin/manager.rs | 600 ---------------- src/plugin/mod.rs | 188 ----- src/plugin/native.rs | 40 -- src/plugin/native_handlers/macros.rs | 27 - src/plugin/native_handlers/mod.rs | 126 ---- src/plugin/native_handlers/session.rs | 219 ------ src/plugin/native_handlers/ui.rs | 143 ---- src/plugin/plog.rs | 34 - src/plugin/plugins.rs | 659 ------------------ src/server/connection.rs | 89 --- src/ui_session_interface.rs | 10 - 93 files changed, 8 insertions(+), 5251 deletions(-) delete mode 100644 flutter/lib/plugin/common.dart delete mode 100644 flutter/lib/plugin/event.dart delete mode 100644 flutter/lib/plugin/handlers.dart delete mode 100644 flutter/lib/plugin/manager.dart delete mode 100644 flutter/lib/plugin/model.dart delete mode 100644 flutter/lib/plugin/ui_manager.dart delete mode 100644 flutter/lib/plugin/utils/dialogs.dart delete mode 100644 flutter/lib/plugin/widgets/desc_ui.dart delete mode 100644 flutter/lib/plugin/widgets/desktop_settings.dart delete mode 100644 flutter/lib/web/plugin/handlers.dart delete mode 100644 src/plugin/callback_ext.rs delete mode 100644 src/plugin/callback_msg.rs delete mode 100644 src/plugin/config.rs delete mode 100644 src/plugin/desc.rs delete mode 100644 src/plugin/errno.rs delete mode 100644 src/plugin/ipc.rs delete mode 100644 src/plugin/manager.rs delete mode 100644 src/plugin/mod.rs delete mode 100644 src/plugin/native.rs delete mode 100644 src/plugin/native_handlers/macros.rs delete mode 100644 src/plugin/native_handlers/mod.rs delete mode 100644 src/plugin/native_handlers/session.rs delete mode 100644 src/plugin/native_handlers/ui.rs delete mode 100644 src/plugin/plog.rs delete mode 100644 src/plugin/plugins.rs diff --git a/Cargo.lock b/Cargo.lock index cb08cdad2..9272b562a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -986,27 +986,6 @@ dependencies = [ "serde 1.0.228", ] -[[package]] -name = "bzip2" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" -dependencies = [ - "bzip2-sys", - "libc", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.11+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "cacao" version = "0.4.0-beta2" @@ -1477,8 +1456,8 @@ dependencies = [ "compression-core", "flate2", "memchr", - "zstd 0.13.1", - "zstd-safe 7.1.0", + "zstd", + "zstd-safe", ] [[package]] @@ -1549,12 +1528,6 @@ dependencies = [ "unicode-xid 0.2.4", ] -[[package]] -name = "constant_time_eq" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" - [[package]] name = "constant_time_eq" version = "0.2.6" @@ -3825,7 +3798,7 @@ dependencies = [ "whoami", "winapi 0.3.9", "x11 2.21.0", - "zstd 0.13.1", + "zstd", ] [[package]] @@ -6057,35 +6030,12 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "password-hash" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pbkdf2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" -dependencies = [ - "digest", - "hmac", - "password-hash", - "sha2", -] - [[package]] name = "peeking_take_while" version = "0.1.2" @@ -7365,7 +7315,6 @@ dependencies = [ "wol-rs", "x11-clipboard 0.8.1", "x11rb 0.12.0", - "zip", ] [[package]] @@ -8907,7 +8856,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c4ae9724c5888c0417d2396037ed3b60665925624766416e3e342b6ba5dbd3f" dependencies = [ "base32", - "constant_time_eq 0.2.6", + "constant_time_eq", "hmac", "rand 0.8.5", "sha1", @@ -11157,52 +11106,13 @@ dependencies = [ "syn 2.0.98", ] -[[package]] -name = "zip" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" -dependencies = [ - "aes", - "byteorder", - "bzip2", - "constant_time_eq 0.1.5", - "crc32fast", - "crossbeam-utils", - "flate2", - "hmac", - "pbkdf2", - "sha1", - "time 0.3.36", - "zstd 0.11.2+zstd.1.5.2", -] - -[[package]] -name = "zstd" -version = "0.11.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" -dependencies = [ - "zstd-safe 5.0.2+zstd.1.5.2", -] - [[package]] name = "zstd" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d789b1514203a1120ad2429eae43a7bd32b90976a7bb8a05f7ec02fa88cc23a" dependencies = [ - "zstd-safe 7.1.0", -] - -[[package]] -name = "zstd-safe" -version = "5.0.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" -dependencies = [ - "libc", - "zstd-sys", + "zstd-safe", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2fac88c00..588cbd96a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,6 @@ drm = ["scrap/drm"] # kind of operation and deserves a switch that can remove it from the binary entirely, without # giving up DRM capture: `--features drm` builds the capture path with no wake code compiled in. drm-wake = ["drm"] -plugin_framework = [] linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"] unix-file-copy-paste = [ "dep:x11-clipboard", @@ -81,7 +80,6 @@ hex = "0.4" chrono = "0.4" cidr-utils = "0.5" fon = "0.6" -zip = "0.6" shutdown_hooks = "0.1" totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] } stunclient = "0.4" @@ -212,7 +210,7 @@ android-wakelock = { git = "https://github.com/rustdesk-org/android-wakelock" } [workspace] members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable", "libs/remote_printer"] -exclude = ["vdi/host", "examples/custom_plugin"] +exclude = ["vdi/host"] # Patch libxdo-sys to use a stub implementation that doesn't require libxdo # This allows building and running on systems without libxdo installed (e.g., Wayland-only) diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index 42ec10032..76d464198 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -16,7 +16,6 @@ import 'package:flutter_hbb/desktop/widgets/update_progress.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; -import 'package:flutter_hbb/plugin/ui_manager.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:flutter_hbb/utils/platform_channel.dart'; import 'package:get/get.dart'; @@ -111,7 +110,6 @@ class _DesktopHomePageState extends State } }, ), - buildPluginEntry(), ]; if (isIncomingOnly) { children.addAll([ @@ -890,21 +888,6 @@ class _DesktopHomePageState extends State shouldBeBlocked(_block, canBeBlocked); } } - - Widget buildPluginEntry() { - final entries = PluginUiManager.instance.entries.entries; - return Offstage( - offstage: entries.isEmpty, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...entries.map((entry) { - return entry.value; - }) - ], - ), - ); - } } void setPasswordDialog({VoidCallback? notEmptyCallback}) async { diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index b2aab1cfb..a2eb94e42 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -17,8 +17,6 @@ import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/printer_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; -import 'package:flutter_hbb/plugin/manager.dart'; -import 'package:flutter_hbb/plugin/widgets/desktop_settings.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -55,7 +53,6 @@ enum SettingsTabKey { safety, network, display, - plugin, account, printer, about, @@ -74,8 +71,6 @@ class DesktopSettingPage extends StatefulWidget { bind.mainGetBuildinOption(key: kOptionHideNetworkSetting) != 'Y') SettingsTabKey.network, if (!bind.isIncomingOnly()) SettingsTabKey.display, - if (!isWeb && !bind.isIncomingOnly() && bind.pluginFeatureIsEnabled()) - SettingsTabKey.plugin, if (!bind.isDisableAccount()) SettingsTabKey.account, if (isWindows && bind.mainGetBuildinOption(key: kOptionHideRemotePrinterSetting) != 'Y') @@ -171,7 +166,7 @@ class _DesktopSettingPageState extends State void dispose() { super.dispose(); Get.delete(tag: _kSettingPageControllerTag); - Get.delete(tag: _kSettingPageTabKeyTag); + Get.delete>(tag: _kSettingPageTabKeyTag); WidgetsBinding.instance.removeObserver(this); _videoConnTimer?.cancel(); } @@ -196,10 +191,6 @@ class _DesktopSettingPageState extends State settingTabs.add(_TabInfo(tab, 'Display', Icons.desktop_windows_outlined, Icons.desktop_windows)); break; - case SettingsTabKey.plugin: - settingTabs.add(_TabInfo( - tab, 'Plugin', Icons.extension_outlined, Icons.extension)); - break; case SettingsTabKey.account: settingTabs.add( _TabInfo(tab, 'Account', Icons.person_outline, Icons.person)); @@ -233,9 +224,6 @@ class _DesktopSettingPageState extends State case SettingsTabKey.display: children.add(const _Display()); break; - case SettingsTabKey.plugin: - children.add(const _Plugin()); - break; case SettingsTabKey.account: children.add(const _Account()); break; @@ -2255,51 +2243,6 @@ class _CheckboxState extends State<_Checkbox> { } } -class _Plugin extends StatefulWidget { - const _Plugin({Key? key}) : super(key: key); - - @override - State<_Plugin> createState() => _PluginState(); -} - -class _PluginState extends State<_Plugin> { - @override - Widget build(BuildContext context) { - bind.pluginListReload(); - final scrollController = ScrollController(); - return ChangeNotifierProvider.value( - value: pluginManager, - child: Consumer(builder: (context, model, child) { - return ListView( - controller: scrollController, - children: model.plugins.map((entry) => pluginCard(entry)).toList(), - ).marginOnly(bottom: _kListViewBottomMargin); - }), - ); - } - - Widget pluginCard(PluginInfo plugin) { - return ChangeNotifierProvider.value( - value: plugin, - child: Consumer( - builder: (context, model, child) => DesktopSettingsCard(plugin: model), - ), - ); - } - - Widget accountAction() { - return Obx(() => _Button( - gFFI.userModel.userName.value.isEmpty - ? 'Login' - : '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})', - () => { - gFFI.userModel.userName.value.isEmpty - ? loginDialog() - : logOutConfirmDialog() - })); - } -} - class _Printer extends StatefulWidget { const _Printer({super.key}); diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index a9185d6a3..79f382249 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -182,7 +182,6 @@ class _RemotePageState extends State WakelockManager.enable(_uniqueKey); _ffi.ffiModel.updateEventListener(sessionId, widget.id); - if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote); _ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId); _ffi.dialogManager.loadMobileActionsOverlayVisible(); WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/flutter/lib/desktop/pages/view_camera_page.dart b/flutter/lib/desktop/pages/view_camera_page.dart index c45ec4d86..6eb65b11d 100644 --- a/flutter/lib/desktop/pages/view_camera_page.dart +++ b/flutter/lib/desktop/pages/view_camera_page.dart @@ -127,7 +127,6 @@ class _ViewCameraPageState extends State WakelockManager.enable(_uniqueKey); _ffi.ffiModel.updateEventListener(sessionId, widget.id); - if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote); _ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId); _ffi.dialogManager.loadMobileActionsOverlayVisible(); DesktopMultiWindow.addListener(this); diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 2373d016a..0516608cd 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -10,8 +10,6 @@ import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart'; -import 'package:flutter_hbb/plugin/widgets/desc_ui.dart'; -import 'package:flutter_hbb/plugin/common.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; @@ -1478,20 +1476,13 @@ class _DisplayMenu extends StatefulWidget { final FFI ffi; final ToolbarState state; final Function(bool) setFullscreen; - final Widget pluginItem; _DisplayMenu( {Key? key, required this.id, required this.ffi, required this.state, required this.setFullscreen}) - : pluginItem = LocationItem.createLocationItem( - id, - ffi, - kLocationClientRemoteToolbarDisplay, - true, - ), - super(key: key); + : super(key: key); @override State<_DisplayMenu> createState() => _DisplayMenuState(); @@ -1582,9 +1573,6 @@ class _DisplayMenuState extends State<_DisplayMenu> { ]); } } - if (ffi.connType == ConnType.defaultConn) { - menuChildren.add(widget.pluginItem); - } return menuChildren; } diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 7e0a8cb2b..5f234cb69 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -30,9 +30,6 @@ import 'mobile/pages/server_page.dart'; import 'mobile/widgets/deploy_dialog.dart'; import 'models/platform_model.dart'; -import 'package:flutter_hbb/plugin/handlers.dart' - if (dart.library.html) 'package:flutter_hbb/web/plugin/handlers.dart'; - /// Basic window and launch properties. int? kWindowId; WindowType? kWindowType; @@ -141,8 +138,6 @@ void runMainApp(bool startService) async { await bind.mainCheckConnectStatus(); if (startService) { gFFI.serverModel.startService(); - bind.pluginSyncUi(syncTo: kAppTypeMain); - bind.pluginListReload(); } await Future.wait([gFFI.abModel.loadCache(), gFFI.groupModel.loadCache()]); gFFI.userModel.refreshCurrentUser(); @@ -570,12 +565,6 @@ _registerEventHandler() { reloadAllWindows(); }); } - // Register native handlers. - if (isDesktop) { - platformFFI.registerEventHandler('native_ui', 'native_ui', (evt) async { - NativeUiHandler.instance.onEvent(evt); - }); - } if (isAndroid) { platformFFI.registerEventHandler( 'android_needs_deploy', 'android_needs_deploy', (_) async { diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 4a6088bd3..68ec58cc3 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -25,9 +25,6 @@ import 'package:flutter_hbb/models/user_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/models/desktop_render_texture.dart'; import 'package:flutter_hbb/models/terminal_model.dart'; -import 'package:flutter_hbb/plugin/event.dart'; -import 'package:flutter_hbb/plugin/manager.dart'; -import 'package:flutter_hbb/plugin/widgets/desc_ui.dart'; import 'package:flutter_hbb/common/shared_state.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:flutter_hbb/utils/http_service.dart' as http; @@ -437,15 +434,6 @@ class FfiModel with ChangeNotifier { parent.target?.serverModel.updateVoiceCallState(evt); } else if (name == 'fingerprint') { FingerprintState.find(peerId).value = evt['fingerprint'] ?? ''; - } else if (name == 'plugin_manager') { - pluginManager.handleEvent(evt); - } else if (name == 'plugin_event') { - handlePluginEvent(evt, - (Map e) => handleMsgBox(e, sessionId, peerId)); - } else if (name == 'plugin_reload') { - handleReloading(evt); - } else if (name == 'plugin_option') { - handleOption(evt); } else if (name == "sync_peer_hash_password_to_personal_ab") { if (desktopType == DesktopType.main || isWeb || isMobile) { final id = evt['id']; diff --git a/flutter/lib/plugin/common.dart b/flutter/lib/plugin/common.dart deleted file mode 100644 index d984c68ea..000000000 --- a/flutter/lib/plugin/common.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'dart:convert'; - -typedef PluginId = String; - -// ui location -const String kLocationHostMainPlugin = 'host|main|settings|plugin'; -const String kLocationClientRemoteToolbarDisplay = - 'client|remote|toolbar|display'; - -class MsgFromUi { - String id; - String name; - String location; - String key; - String value; - String action; - - MsgFromUi({ - required this.id, - required this.name, - required this.location, - required this.key, - required this.value, - required this.action, - }); - - Map toJson() { - return { - 'id': id, - 'name': name, - 'location': location, - 'key': key, - 'value': value, - 'action': action, - }; - } - - @override - String toString() { - return jsonEncode(toJson()); - } -} diff --git a/flutter/lib/plugin/event.dart b/flutter/lib/plugin/event.dart deleted file mode 100644 index 29a2ae44c..000000000 --- a/flutter/lib/plugin/event.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:convert'; -import 'package:flutter/material.dart'; - -void handlePluginEvent( - Map evt, - Function(Map e) handleMsgBox, -) { - Map? content; - try { - content = json.decode(evt['content']); - } catch (e) { - debugPrint( - 'Json decode plugin event content failed: $e, ${evt['content']}'); - } - if (content?['t'] == 'MsgBox') { - handleMsgBox(content?['c']); - } -} diff --git a/flutter/lib/plugin/handlers.dart b/flutter/lib/plugin/handlers.dart deleted file mode 100644 index c85f4dfca..000000000 --- a/flutter/lib/plugin/handlers.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'dart:convert'; -import 'dart:ffi'; - -import 'package:ffi/ffi.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hbb/plugin/ui_manager.dart'; -import 'package:flutter_hbb/plugin/utils/dialogs.dart'; - -abstract class NativeHandler { - bool onEvent(Map evt); -} - -typedef OnSelectPeersCallback = Bool Function(Int returnCode, - Pointer data, Uint64 dataLength, Pointer userData); -typedef OnSelectPeersCallbackDart = bool Function( - int returnCode, Pointer data, int dataLength, Pointer userData); - -class NativeUiHandler extends NativeHandler { - NativeUiHandler._(); - - static NativeUiHandler instance = NativeUiHandler._(); - - @override - bool onEvent(Map evt) { - final name = evt['name']; - final action = evt['action']; - if (name != "native_ui") { - return false; - } - switch (action) { - case "select_peers": - int cb = evt['cb']; - int userData = evt['user_data'] ?? 0; - final cbFuncNative = Pointer.fromAddress(cb) - .cast>(); - final cbFuncDart = cbFuncNative.asFunction(); - onSelectPeers(cbFuncDart, userData); - break; - case "register_ui_entry": - int cb = evt['on_tap_cb']; - int userData = evt['user_data'] ?? 0; - String title = evt['title'] ?? ""; - final cbFuncNative = Pointer.fromAddress(cb) - .cast>(); - final cbFuncDart = cbFuncNative.asFunction(); - onRegisterUiEntry(title, cbFuncDart, userData); - break; - default: - return false; - } - return true; - } - - void onSelectPeers(OnSelectPeersCallbackDart cb, int userData) async { - showPeerSelectionDialog(onPeersCallback: (peers) { - String json = jsonEncode( { - "peers": peers - }); - final native = json.toNativeUtf8(); - cb(0, native.cast(), native.length, Pointer.fromAddress(userData)); - malloc.free(native); - }); - } - - void onRegisterUiEntry(String title, OnSelectPeersCallbackDart cbFuncDart, int userData) { - Widget widget = InkWell( - child: Container( - height: 25.0, - child: Row( - children: [ - Expanded(child: Text(title)), - Icon(Icons.chevron_right_rounded, size: 12.0,) - ], - ), - ), - ); - PluginUiManager.instance.registerEntry(title, widget); - } -} diff --git a/flutter/lib/plugin/manager.dart b/flutter/lib/plugin/manager.dart deleted file mode 100644 index f58a1a54e..000000000 --- a/flutter/lib/plugin/manager.dart +++ /dev/null @@ -1,319 +0,0 @@ -// The plugin manager is a singleton class that manages the plugins. -// 1. It merge metadata and the desc of plugins. - -import 'dart:convert'; -import 'dart:collection'; -import 'package:flutter/material.dart'; - -const String kValueTrue = '1'; -const String kValueFalse = '0'; - -class ConfigItem { - String key; - String description; - String defaultValue; - - ConfigItem(this.key, this.defaultValue, this.description); - ConfigItem.fromJson(Map json) - : key = json['key'] ?? '', - description = json['description'] ?? '', - defaultValue = json['default'] ?? ''; - - static String get trueValue => kValueTrue; - static String get falseValue => kValueFalse; - static bool isTrue(String value) => value == kValueTrue; - static bool isFalse(String value) => value == kValueFalse; -} - -class UiType { - String key; - String text; - String tooltip; - String action; - - UiType(this.key, this.text, this.tooltip, this.action); - - UiType.fromJson(Map json) - : key = json['key'] ?? '', - text = json['text'] ?? '', - tooltip = json['tooltip'] ?? '', - action = json['action'] ?? ''; - - static UiType? create(Map json) { - if (json['t'] == 'Button') { - return UiButton.fromJson(json['c']); - } else if (json['t'] == 'Checkbox') { - return UiCheckbox.fromJson(json['c']); - } else { - return null; - } - } -} - -class UiButton extends UiType { - String icon; - - UiButton( - {required String key, - required String text, - required this.icon, - required String tooltip, - required String action}) - : super(key, text, tooltip, action); - - UiButton.fromJson(Map json) - : icon = json['icon'] ?? '', - super.fromJson(json); -} - -class UiCheckbox extends UiType { - UiCheckbox( - {required String key, - required String text, - required String tooltip, - required String action}) - : super(key, text, tooltip, action); - - UiCheckbox.fromJson(Map json) : super.fromJson(json); -} - -class Location { - // location key: - // host|main|settings|plugin - // client|remote|toolbar|display - HashMap ui; - - Location(this.ui); - Location.fromJson(Map json) : ui = HashMap() { - (json['ui'] as Map).forEach((key, value) { - var ui = UiType.create(value); - if (ui != null) { - this.ui[ui.key] = ui; - } - }); - } -} - -class PublishInfo { - PublishInfo({ - required this.lastReleased, - required this.published, - }); - - final DateTime lastReleased; - final DateTime published; -} - -class Meta { - Meta({ - required this.id, - required this.name, - required this.version, - required this.description, - required this.author, - required this.home, - required this.license, - required this.publishInfo, - required this.source, - }); - - final String id; - final String name; - final String version; - final String description; - final String author; - final String home; - final String license; - final PublishInfo publishInfo; - final String source; -} - -class SourceInfo { - String name; // 1. RustDesk github 2. Local - String url; - String description; - - SourceInfo({ - required this.name, - required this.url, - required this.description, - }); -} - -class PluginInfo with ChangeNotifier { - SourceInfo sourceInfo; - Meta meta; - String installedVersion; // It is empty if not installed. - String failedMsg; - String invalidReason; // It is empty if valid. - - PluginInfo({ - required this.sourceInfo, - required this.meta, - required this.installedVersion, - required this.invalidReason, - this.failedMsg = '', - }); - - bool get installed => installedVersion.isNotEmpty; - bool get needUpdate => installed && installedVersion != meta.version; - - void setInstall(String msg) { - if (msg == "finished") { - msg = ''; - } - failedMsg = msg; - if (msg.isEmpty) { - installedVersion = meta.version; - } - notifyListeners(); - } - - void setUninstall(String msg) { - failedMsg = msg; - if (msg.isEmpty) { - installedVersion = ''; - } - notifyListeners(); - } -} - -class PluginManager with ChangeNotifier { - String failedReason = ''; // The reason of failed to load plugins. - final List _plugins = []; - - PluginManager._(); - static final PluginManager _instance = PluginManager._(); - static PluginManager get instance => _instance; - - List get plugins => _plugins; - - PluginInfo? getPlugin(String id) { - for (var p in _plugins) { - if (p.meta.id == id) { - return p; - } - } - return null; - } - - void handleEvent(Map evt) { - if (evt['plugin_list'] != null) { - _handlePluginList(evt['plugin_list']); - } else if (evt['plugin_install'] != null && evt['id'] != null) { - _handlePluginInstall(evt['id'], evt['plugin_install']); - } else if (evt['plugin_uninstall'] != null && evt['id'] != null) { - _handlePluginUninstall(evt['id'], evt['plugin_uninstall']); - } else { - debugPrint('Failed to handle manager event: $evt'); - } - } - - void _sortPlugins() { - plugins.sort((a, b) { - if (a.installed) { - return -1; - } else if (b.installed) { - return 1; - } else { - return 0; - } - }); - } - - void _handlePluginList(String pluginList) { - _plugins.clear(); - try { - for (var p in json.decode(pluginList) as List) { - final plugin = _getPluginFromEvent(p); - if (plugin == null) { - continue; - } - _plugins.add(plugin); - } - } catch (e) { - debugPrint('Failed to decode $e, plugin list \'$pluginList\''); - } - _sortPlugins(); - notifyListeners(); - } - - void _handlePluginInstall(String id, String msg) { - debugPrint('Plugin \'$id\' install msg $msg'); - for (var i = 0; i < _plugins.length; i++) { - if (_plugins[i].meta.id == id) { - _plugins[i].setInstall(msg); - _sortPlugins(); - notifyListeners(); - return; - } - } - } - - void _handlePluginUninstall(String id, String msg) { - debugPrint('Plugin \'$id\' uninstall msg $msg'); - for (var i = 0; i < _plugins.length; i++) { - if (_plugins[i].meta.id == id) { - _plugins[i].setUninstall(msg); - _sortPlugins(); - notifyListeners(); - return; - } - } - } - - PluginInfo? _getPluginFromEvent(Map evt) { - final s = evt['source']; - assert(s != null, 'Source is null'); - if (s == null) { - return null; - } - final source = SourceInfo( - name: s['name'], - url: s['url'] ?? '', - description: s['description'] ?? '', - ); - - final m = evt['meta']; - assert(m != null, 'Meta is null'); - if (m == null) { - return null; - } - - late DateTime lastReleased; - late DateTime published; - try { - lastReleased = DateTime.parse( - m['publish_info']?['last_released'] ?? '1970-01-01T00+00:00'); - } catch (e) { - lastReleased = DateTime.utc(1970); - } - try { - published = DateTime.parse( - m['publish_info']?['published'] ?? '1970-01-01T00+00:00'); - } catch (e) { - published = DateTime.utc(1970); - } - - final meta = Meta( - id: m['id'], - name: m['name'], - version: m['version'], - description: m['description'] ?? '', - author: m['author'], - home: m['home'] ?? '', - license: m['license'] ?? '', - source: m['source'] ?? '', - publishInfo: - PublishInfo(lastReleased: lastReleased, published: published), - ); - return PluginInfo( - sourceInfo: source, - meta: meta, - installedVersion: evt['installed_version'], - invalidReason: evt['invalid_reason'] ?? '', - ); - } -} - -PluginManager get pluginManager => PluginManager.instance; diff --git a/flutter/lib/plugin/model.dart b/flutter/lib/plugin/model.dart deleted file mode 100644 index 4fc024e4c..000000000 --- a/flutter/lib/plugin/model.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'package:flutter/material.dart'; -import './common.dart'; -import './manager.dart'; - -final Map _locationModels = {}; -final Map _optionModels = {}; - -class OptionModel with ChangeNotifier { - String? v; - - String? get value => v; - set value(String? v) { - this.v = v; - notifyListeners(); - } - - static String key(String location, PluginId id, String peer, String k) => - '$location|$id|$peer|$k'; -} - -class PluginModel with ChangeNotifier { - final List uiList = []; - final Map opts = {}; - - void add(List uiList) { - bool found = false; - for (var ui in uiList) { - for (int i = 0; i < this.uiList.length; i++) { - if (this.uiList[i].key == ui.key) { - this.uiList[i] = ui; - found = true; - } - } - if (!found) { - this.uiList.add(ui); - } - } - notifyListeners(); - } - - String? getOpt(String key) => opts.remove(key); - - bool get isEmpty => uiList.isEmpty; -} - -class LocationModel with ChangeNotifier { - final Map pluginModels = {}; - - void add(PluginId id, List uiList) { - if (pluginModels[id] != null) { - pluginModels[id]!.add(uiList); - } else { - var model = PluginModel(); - model.add(uiList); - pluginModels[id] = model; - notifyListeners(); - } - } - - void clear() { - pluginModels.clear(); - notifyListeners(); - } - - void remove(PluginId id) { - pluginModels.remove(id); - notifyListeners(); - } - - bool get isEmpty => pluginModels.isEmpty; -} - -void addLocationUi(String location, PluginId id, List uiList) { - if (_locationModels[location] == null) { - _locationModels[location] = LocationModel(); - } - _locationModels[location]?.add(id, uiList); -} - -LocationModel? getLocationModel(String location) => _locationModels[location]; - -PluginModel? getPluginModel(String location, PluginId id) => - _locationModels[location]?.pluginModels[id]; - -void clearPlugin(PluginId pluginId) { - for (var element in _locationModels.values) { - element.remove(pluginId); - } -} - -void clearLocations() { - for (var element in _locationModels.values) { - element.clear(); - } -} - -OptionModel getOptionModel( - String location, PluginId pluginId, String peer, String key) { - final k = OptionModel.key(location, pluginId, peer, key); - if (_optionModels[k] == null) { - _optionModels[k] = OptionModel(); - } - return _optionModels[k]!; -} - -void updateOption( - String location, PluginId id, String peer, String key, String value) { - final k = OptionModel.key(location, id, peer, key); - _optionModels[k]?.value = value; -} diff --git a/flutter/lib/plugin/ui_manager.dart b/flutter/lib/plugin/ui_manager.dart deleted file mode 100644 index 45accf650..000000000 --- a/flutter/lib/plugin/ui_manager.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/material.dart'; - -class PluginUiManager { - PluginUiManager._(); - - static PluginUiManager instance = PluginUiManager._(); - - Map entries = {}; - - void registerEntry(String key, Widget widget) { - entries[key] = widget; - } - - void unregisterEntry(String key) { - entries.remove(key); - } -} \ No newline at end of file diff --git a/flutter/lib/plugin/utils/dialogs.dart b/flutter/lib/plugin/utils/dialogs.dart deleted file mode 100644 index 6fdb86ab4..000000000 --- a/flutter/lib/plugin/utils/dialogs.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_hbb/common.dart'; - -void showPeerSelectionDialog( - {bool singleSelection = false, - required Function(List) onPeersCallback}) async { - // load recent peers, we can directly use the peers in `gFFI.recentPeersModel`. - // The plugin is not used for now, so just left it empty here. - final peers = ''; - if (peers.isEmpty) { - // debugPrint("load recent peers failed."); - return; - } - - Map map = jsonDecode(peers); - List peersList = map['peers'] ?? []; - final selected = List.empty(growable: true); - - submit() async { - onPeersCallback.call(selected); - } - - gFFI.dialogManager.show((setState, close, context) { - return CustomAlertDialog( - title: - Text(translate(singleSelection ? "Select peers" : "Select a peer")), - content: SizedBox( - height: 300.0, - child: ListView.builder( - itemBuilder: (context, index) { - final Map peer = peersList[index]; - final String platform = peer['platform'] ?? ""; - final String id = peer['id'] ?? ""; - final String alias = peer['alias'] ?? ""; - return GestureDetector( - onTap: () { - setState(() { - if (selected.contains(id)) { - selected.remove(id); - } else { - selected.add(id); - } - }); - }, - child: Container( - key: ValueKey(index), - height: 50.0, - decoration: BoxDecoration( - color: Theme.of(context).highlightColor, - borderRadius: BorderRadius.circular(12.0)), - padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), - margin: EdgeInsets.symmetric(vertical: 4.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - // platform - SizedBox( - width: 8.0, - ), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - getPlatformImage(platform, size: 34.0), - ], - ), - SizedBox( - width: 8.0, - ), - // id/alias - Expanded(child: Text(alias.isEmpty ? id : alias)), - ], - ), - ), - ); - }, - itemCount: peersList.length, - itemExtent: 50.0, - ), - ), - onSubmit: submit, - ); - }); -} diff --git a/flutter/lib/plugin/widgets/desc_ui.dart b/flutter/lib/plugin/widgets/desc_ui.dart deleted file mode 100644 index 10c231f98..000000000 --- a/flutter/lib/plugin/widgets/desc_ui.dart +++ /dev/null @@ -1,301 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_hbb/common.dart'; -import 'package:flutter_hbb/models/model.dart'; -import 'package:provider/provider.dart'; -import 'package:get/get.dart'; -// to-do: do not depend on desktop -import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart'; -import 'package:flutter_hbb/models/platform_model.dart'; - -import '../manager.dart'; -import '../model.dart'; -import '../common.dart'; - -// dup to flutter\lib\desktop\pages\desktop_setting_page.dart -const double _kCheckBoxLeftMargin = 10; - -class LocationItem extends StatelessWidget { - final String peerId; - final FFI ffi; - final String location; - final LocationModel locationModel; - final bool isMenu; - - LocationItem({ - Key? key, - required this.peerId, - required this.ffi, - required this.location, - required this.locationModel, - required this.isMenu, - }) : super(key: key); - - bool get isEmpty => locationModel.isEmpty; - - static Widget createLocationItem( - String peerId, FFI ffi, String location, bool isMenu) { - final model = getLocationModel(location); - return model == null - ? Container() - : LocationItem( - peerId: peerId, - ffi: ffi, - location: location, - locationModel: model, - isMenu: isMenu, - ); - } - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider.value( - value: locationModel, - child: Consumer(builder: (context, model, child) { - return Column( - children: model.pluginModels.entries - .map((entry) => _buildPluginItem(entry.key, entry.value)) - .toList(), - ); - }), - ); - } - - Widget _buildPluginItem(PluginId id, PluginModel model) => PluginItem( - pluginId: id, - peerId: peerId, - ffi: ffi, - location: location, - pluginModel: model, - isMenu: isMenu, - ); -} - -class PluginItem extends StatelessWidget { - final PluginId pluginId; - final String peerId; - final FFI? ffi; - final String location; - final PluginModel pluginModel; - final bool isMenu; - - PluginItem({ - Key? key, - required this.pluginId, - required this.peerId, - this.ffi, - required this.location, - required this.pluginModel, - required this.isMenu, - }) : super(key: key); - - bool get isEmpty => pluginModel.isEmpty; - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider.value( - value: pluginModel, - child: Consumer( - builder: (context, pluginModel, child) { - return Column( - children: pluginModel.uiList.map((ui) => _buildItem(ui)).toList(), - ); - }, - ), - ); - } - - Widget _buildItem(UiType ui) { - Widget? child; - switch (ui.runtimeType) { - case UiButton: - if (isMenu) { - if (ffi != null) { - child = _buildMenuButton(ui as UiButton, ffi!); - } - } else { - child = _buildButton(ui as UiButton); - } - break; - case UiCheckbox: - if (isMenu) { - if (ffi != null) { - child = _buildCheckboxMenuButton(ui as UiCheckbox, ffi!); - } - } else { - child = _buildCheckbox(ui as UiCheckbox); - } - break; - default: - break; - } - // to-do: add plugin icon and tooltip - return child ?? Container(); - } - - Widget _buildButton(UiButton ui) { - return TextButton( - onPressed: () => bind.pluginEvent( - id: pluginId, - peer: peerId, - event: _makeEvent(ui.key), - ), - child: Text(ui.text), - ); - } - - Widget _buildCheckbox(UiCheckbox ui) { - getChild(OptionModel model) { - final v = _getOption(model, ui.key); - if (v == null) { - // session or plugin not found - return Container(); - } - - onChanged(bool value) { - bind.pluginEvent( - id: pluginId, - peer: peerId, - event: _makeEvent(ui.key, v: value), - ); - } - - final value = ConfigItem.isTrue(v); - return GestureDetector( - child: Row( - children: [ - Checkbox( - value: value, - onChanged: (_) => onChanged(!value), - ).marginOnly(right: 5), - Expanded( - child: Text(translate(ui.text)), - ) - ], - ).marginOnly(left: _kCheckBoxLeftMargin), - onTap: () => onChanged(!value), - ); - } - - return ChangeNotifierProvider.value( - value: getOptionModel(location, pluginId, peerId, ui.key), - child: Consumer( - builder: (context, model, child) => getChild(model), - ), - ); - } - - Widget _buildCheckboxMenuButton(UiCheckbox ui, FFI ffi) { - getChild(OptionModel model) { - final v = _getOption(model, ui.key); - if (v == null) { - // session or plugin not found - return Container(); - } - return CkbMenuButton( - value: ConfigItem.isTrue(v), - onChanged: (v) { - if (v != null) { - bind.pluginEvent( - id: pluginId, - peer: peerId, - event: _makeEvent(ui.key, v: v), - ); - } - }, - // to-do: RustDesk translate or plugin translate ? - child: Text(ui.text), - ffi: ffi, - ); - } - - return ChangeNotifierProvider.value( - value: getOptionModel(location, pluginId, peerId, ui.key), - child: Consumer( - builder: (context, model, child) => getChild(model), - ), - ); - } - - Widget _buildMenuButton(UiButton ui, FFI ffi) { - return MenuButton( - onPressed: () => bind.pluginEvent( - id: pluginId, - peer: peerId, - event: _makeEvent(ui.key), - ), - // to-do: support trailing icon, but it will cause tree shake error. - // ``` - // This application cannot tree shake icons fonts. It has non-constant instances of IconData at the following locations: - // Target release_macos_bundle_flutter_assets failed: Exception: Avoid non-constant invocations of IconData or try to build again with --no-tree-shake-icons. - // ``` - // - // trailingIcon: Icon( - // IconData(int.parse(ui.icon, radix: 16), fontFamily: 'MaterialIcons')), - // - // to-do: RustDesk translate or plugin translate ? - child: Text(ui.text), - ffi: ffi, - ); - } - - Uint8List _makeEvent( - String key, { - bool? v, - }) { - final event = MsgFromUi( - id: pluginId, - name: pluginManager.getPlugin(pluginId)?.meta.name ?? '', - location: location, - key: key, - value: - v != null ? (v ? ConfigItem.trueValue : ConfigItem.falseValue) : '', - action: '', - ); - return Uint8List.fromList(event.toString().codeUnits); - } - - String? _getOption(OptionModel model, String key) { - var v = model.value; - if (v == null) { - try { - if (peerId.isEmpty) { - v = bind.pluginGetSharedOption(id: pluginId, key: key); - } else { - v = bind.pluginGetSessionOption(id: pluginId, peer: peerId, key: key); - } - } catch (e) { - debugPrint('Failed to get option "$key", $e'); - v = null; - } - } - return v; - } -} - -void handleReloading(Map evt) { - if (evt['id'] == null || evt['location'] == null) { - return; - } - try { - final uiList = []; - for (var e in json.decode(evt['ui'] as String)) { - final ui = UiType.create(e); - if (ui != null) { - uiList.add(ui); - } - } - if (uiList.isNotEmpty) { - addLocationUi(evt['location']!, evt['id']!, uiList); - } - } catch (e) { - debugPrint('Failed handleReloading, json decode of ui, $e '); - } -} - -void handleOption(Map evt) { - updateOption( - evt['location'], evt['id'], evt['peer'] ?? '', evt['key'], evt['value']); -} diff --git a/flutter/lib/plugin/widgets/desktop_settings.dart b/flutter/lib/plugin/widgets/desktop_settings.dart deleted file mode 100644 index 232df001f..000000000 --- a/flutter/lib/plugin/widgets/desktop_settings.dart +++ /dev/null @@ -1,202 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hbb/common.dart'; -import 'package:flutter_hbb/models/platform_model.dart'; -import 'package:flutter_hbb/plugin/model.dart'; -import 'package:flutter_hbb/plugin/common.dart'; -import 'package:get/get.dart'; - -import '../manager.dart'; -import './desc_ui.dart'; - -// to-do: use settings from desktop_setting_page.dart -const double _kCardFixedWidth = 540; -const double _kCardLeftMargin = 15; -const double _kContentHMargin = 15; -const double _kTitleFontSize = 20; -const double _kVersionFontSize = 12; - -class DesktopSettingsCard extends StatefulWidget { - final PluginInfo plugin; - DesktopSettingsCard({ - Key? key, - required this.plugin, - }) : super(key: key); - - @override - State createState() => _DesktopSettingsCardState(); -} - -class _DesktopSettingsCardState extends State { - PluginInfo get plugin => widget.plugin; - bool get installed => plugin.installed; - - bool isEnabled = false; - - @override - Widget build(BuildContext context) { - isEnabled = bind.pluginIsEnabled(id: plugin.meta.id); - return Row( - children: [ - Flexible( - child: SizedBox( - width: _kCardFixedWidth, - child: Card( - child: Column( - children: [ - header(), - body(), - ], - ).marginOnly(bottom: 10), - ).marginOnly(left: _kCardLeftMargin, top: 15), - ), - ), - ], - ); - } - - Widget header() { - return Row( - children: [ - headerNameVersion(), - headerInstallEnable(), - ], - ).marginOnly( - left: _kContentHMargin, - top: 10, - bottom: 10, - right: _kContentHMargin, - ); - } - - Widget headerNameVersion() { - return Expanded( - child: Row( - children: [ - Text( - widget.plugin.meta.name, - textAlign: TextAlign.start, - style: const TextStyle( - fontSize: _kTitleFontSize, - ), - ), - SizedBox( - width: 5, - ), - Text( - plugin.meta.version, - textAlign: TextAlign.start, - style: const TextStyle( - fontSize: _kVersionFontSize, - ), - ) - ], - ), - ); - } - - Widget headerButton(String label, VoidCallback onPressed) { - return Container( - child: ElevatedButton( - onPressed: onPressed, - child: Text(translate(label)), - ), - ); - } - - Widget headerInstallEnable() { - final installButton = headerButton( - installed ? 'Uninstall' : 'Install', - () { - bind.pluginInstall( - id: plugin.meta.id, - b: !installed, - ); - }, - ); - - if (installed) { - final updateButton = plugin.needUpdate - ? headerButton('Update', () { - bind.pluginInstall( - id: plugin.meta.id, - b: !installed, - ); - }) - : Container(); - - final enableButton = !installed - ? Container() - : headerButton(isEnabled ? 'Disable' : 'Enable', () { - if (isEnabled) { - clearPlugin(plugin.meta.id); - } - bind.pluginEnable(id: plugin.meta.id, v: !isEnabled); - setState(() {}); - }); - return Row( - children: [ - updateButton, - SizedBox( - width: 10, - ), - installButton, - SizedBox( - width: 10, - ), - enableButton, - ], - ); - } else { - return installButton; - } - } - - Widget body() { - return Column(children: [ - author(), - description(), - more(), - ]).marginOnly( - left: _kCardLeftMargin, - top: 4, - right: _kContentHMargin, - ); - } - - Widget author() { - return Align( - alignment: Alignment.centerLeft, - child: Text(plugin.meta.author), - ); - } - - Widget description() { - return Align( - alignment: Alignment.centerLeft, - child: Text(plugin.meta.description), - ); - } - - Widget more() { - if (!(installed && isEnabled)) { - return Container(); - } - - final List children = []; - final model = getPluginModel(kLocationHostMainPlugin, plugin.meta.id); - if (model != null) { - children.add(PluginItem( - pluginId: plugin.meta.id, - peerId: '', - location: kLocationHostMainPlugin, - pluginModel: model, - isMenu: false, - )); - } - return ExpansionTile( - title: Text('Options'), - controlAffinity: ListTileControlAffinity.leading, - children: children, - ); - } -} diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index ac48dfb0f..b59c769da 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -1644,78 +1644,6 @@ class RustdeskImpl { throw UnimplementedError("sendUrlScheme"); } - Future pluginEvent( - {required String id, - required String peer, - required Uint8List event, - dynamic hint}) { - throw UnimplementedError("pluginEvent"); - } - - Stream pluginRegisterEventStream( - {required String id, dynamic hint}) { - throw UnimplementedError("pluginRegisterEventStream"); - } - - String? pluginGetSessionOption( - {required String id, - required String peer, - required String key, - dynamic hint}) { - throw UnimplementedError("pluginGetSessionOption"); - } - - Future pluginSetSessionOption( - {required String id, - required String peer, - required String key, - required String value, - dynamic hint}) { - throw UnimplementedError("pluginSetSessionOption"); - } - - String? pluginGetSharedOption( - {required String id, required String key, dynamic hint}) { - throw UnimplementedError("pluginGetSharedOption"); - } - - Future pluginSetSharedOption( - {required String id, - required String key, - required String value, - dynamic hint}) { - throw UnimplementedError("pluginSetSharedOption"); - } - - Future pluginReload({required String id, dynamic hint}) { - throw UnimplementedError("pluginReload"); - } - - void pluginEnable({required String id, required bool v, dynamic hint}) { - throw UnimplementedError("pluginEnable"); - } - - bool pluginIsEnabled({required String id, dynamic hint}) { - throw UnimplementedError("pluginIsEnabled"); - } - - bool pluginFeatureIsEnabled({dynamic hint}) { - throw UnimplementedError("pluginFeatureIsEnabled"); - } - - Future pluginSyncUi({required String syncTo, dynamic hint}) { - throw UnimplementedError("pluginSyncUi"); - } - - Future pluginListReload({dynamic hint}) { - throw UnimplementedError("pluginListReload"); - } - - Future pluginInstall( - {required String id, required bool b, dynamic hint}) { - throw UnimplementedError("pluginInstall"); - } - bool isSupportMultiUiSession({required String version, dynamic hint}) { return versionToNumber(v: version) > versionToNumber(v: '1.2.4'); } diff --git a/flutter/lib/web/plugin/handlers.dart b/flutter/lib/web/plugin/handlers.dart deleted file mode 100644 index f159ce9dd..000000000 --- a/flutter/lib/web/plugin/handlers.dart +++ /dev/null @@ -1,14 +0,0 @@ -abstract class NativeHandler { - bool onEvent(Map evt); -} - -class NativeUiHandler extends NativeHandler { - NativeUiHandler._(); - - static NativeUiHandler instance = NativeUiHandler._(); - - @override - bool onEvent(Map evt) { - throw UnimplementedError(); - } -} diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index 1af691429..bc1828fd8 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -1437,14 +1437,6 @@ impl Remote { #[cfg(all(feature = "flutter", feature = "unix-file-copy-paste"))] crate::flutter::update_file_clipboard_required(); - - // on connection established client - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - crate::plugin::handle_listen_event( - crate::plugin::EVENT_ON_CONN_CLIENT.to_owned(), - self.handler.get_id(), - ); } if self.handler.is_file_transfer() { @@ -1988,26 +1980,6 @@ impl Remote { ); } } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - Some(misc::Union::PluginRequest(p)) => { - allow_err!(crate::plugin::handle_server_event( - &p.id, - &self.handler.get_id(), - &p.content - )); - // to-do: show message box on UI when error occurs? - } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - Some(misc::Union::PluginFailure(p)) => { - let name = if p.name.is_empty() { - "plugin".to_string() - } else { - p.name - }; - self.handler.msgbox("custom-nocancel", &name, &p.msg, ""); - } Some(misc::Union::SupportedEncoding(e)) => { log::info!("update supported encoding:{:?}", e); self.handler.lc.write().unwrap().supported_encoding = e; diff --git a/src/core_main.rs b/src/core_main.rs index b20ecd92b..3a190f114 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -190,9 +190,6 @@ pub fn core_main() -> Option> { crate::platform::elevate_or_run_as_system(click_setup, _is_elevate, _is_run_as_system); return None; } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - init_plugins(&args); if args.is_empty() || crate::common::is_empty_uni_link(&args[0]) { #[cfg(target_os = "macos")] { @@ -737,22 +734,6 @@ pub fn core_main() -> Option> { crate::platform::gtk_sudo::exec(); } return None; - } else { - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - if args[0] == "--plugin-install" { - if args.len() == 2 { - crate::plugin::change_uninstall_plugin(&args[1], false); - } else if args.len() == 3 { - crate::plugin::install_plugin_with_url(&args[1], &args[2]); - } - return None; - } else if args[0] == "--plugin-uninstall" { - if args.len() == 2 { - crate::plugin::change_uninstall_plugin(&args[1], true); - } - return None; - } } } //_async_logger_holder.map(|x| x.flush()); @@ -762,23 +743,6 @@ pub fn core_main() -> Option> { return Some(args); } -#[inline] -#[cfg(all(feature = "flutter", feature = "plugin_framework"))] -#[cfg(not(any(target_os = "android", target_os = "ios")))] -fn init_plugins(args: &Vec) { - if args.is_empty() || "--server" == (&args[0] as &str) { - #[cfg(debug_assertions)] - let load_plugins = true; - #[cfg(not(debug_assertions))] - let load_plugins = crate::platform::is_installed(); - if load_plugins { - crate::plugin::init(); - } - } else if "--service" == (&args[0] as &str) { - hbb_common::allow_err!(crate::plugin::remove_uninstalled()); - } -} - fn import_config(path: &str) { use hbb_common::{config::*, get_exe_time, get_modified_time}; let path2 = path.replace(".toml", "2.toml"); diff --git a/src/flutter.rs b/src/flutter.rs index f6e3d3edd..87c9c02af 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -225,8 +225,6 @@ pub struct FlutterHandler { session_handlers: Arc>>, display_rgbas: Arc>>, peer_info: Arc>, - #[cfg(not(any(target_os = "android", target_os = "ios")))] - hooks: Arc>>, use_texture_render: Arc, } @@ -236,8 +234,6 @@ impl Default for FlutterHandler { session_handlers: Default::default(), display_rgbas: Default::default(), peer_info: Default::default(), - #[cfg(not(any(target_os = "android", target_os = "ios")))] - hooks: Default::default(), use_texture_render: Arc::new( AtomicBool::new(crate::ui_interface::use_texture_render()), ), @@ -636,30 +632,6 @@ impl FlutterHandler { serde_json::ser::to_string(&msg_vec).unwrap_or("".to_owned()) } - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - pub(crate) fn add_session_hook(&self, key: String, hook: SessionHook) -> bool { - let mut hooks = self.hooks.write().unwrap(); - if hooks.contains_key(&key) { - // Already has the hook with this key. - return false; - } - let _ = hooks.insert(key, hook); - true - } - - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - pub(crate) fn remove_session_hook(&self, key: &String) -> bool { - let mut hooks = self.hooks.write().unwrap(); - if !hooks.contains_key(key) { - // The hook with this key does not found. - return false; - } - let _ = hooks.remove(key); - true - } - pub fn update_use_texture_render(&self) { self.use_texture_render .store(crate::ui_interface::use_texture_render(), Ordering::Relaxed); @@ -1194,15 +1166,6 @@ impl InvokeUiSession for FlutterHandler { impl FlutterHandler { #[inline] fn on_rgba_soft_render(&self, display: usize, rgba: &mut scrap::ImageRgb) { - // Give a chance for plugins or etc to hook a rgba data. - #[cfg(not(any(target_os = "android", target_os = "ios")))] - for (key, hook) in self.hooks.read().unwrap().iter() { - match hook { - SessionHook::OnSessionRgba(cb) => { - cb(key.to_owned(), rgba); - } - } - } // If the current rgba is not fetched by flutter, i.e., is valid. // We give up sending a new event to flutter. let mut rgba_write_lock = self.display_rgbas.write().unwrap(); @@ -1963,12 +1926,6 @@ pub fn session_on_waiting_for_image_dialog_show(session_id: SessionID) { } } -/// Hooks for session. -#[derive(Clone)] -pub enum SessionHook { - OnSessionRgba(fn(String, &mut scrap::ImageRgb)), -} - #[inline] pub fn get_cur_session() -> Option { sessions::get_session_by_session_id(&*CUR_SESSION_ID.read().unwrap()) diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 9b73c4cd4..091fcef25 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -12,9 +12,6 @@ use crate::{ ui_interface::{self, *}, }; use flutter_rust_bridge::{StreamSink, SyncReturn}; -#[cfg(feature = "plugin_framework")] -#[cfg(not(any(target_os = "android", target_os = "ios")))] -use hbb_common::allow_err; use hbb_common::{ config::{self, LocalConfig, PeerConfig, PeerInfoSerde}, fs, lazy_static, log, @@ -2522,180 +2519,6 @@ pub fn send_url_scheme(_url: String) { std::thread::spawn(move || crate::handle_url_scheme(_url)); } -#[inline] -pub fn plugin_event(_id: String, _peer: String, _event: Vec) { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - allow_err!(crate::plugin::handle_ui_event(&_id, &_peer, &_event)); - } -} - -pub fn plugin_register_event_stream(_id: String, _event2ui: StreamSink) { - #[cfg(feature = "plugin_framework")] - { - crate::plugin::native_handlers::session::session_register_event_stream(_id, _event2ui); - } -} - -#[inline] -pub fn plugin_get_session_option( - _id: String, - _peer: String, - _key: String, -) -> SyncReturn> { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - SyncReturn(crate::plugin::PeerConfig::get(&_id, &_peer, &_key)) - } - #[cfg(any( - not(feature = "plugin_framework"), - target_os = "android", - target_os = "ios" - ))] - { - SyncReturn(None) - } -} - -#[inline] -pub fn plugin_set_session_option(_id: String, _peer: String, _key: String, _value: String) { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - let _res = crate::plugin::PeerConfig::set(&_id, &_peer, &_key, &_value); - } -} - -#[inline] -pub fn plugin_get_shared_option(_id: String, _key: String) -> SyncReturn> { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - SyncReturn(crate::plugin::ipc::get_config(&_id, &_key).unwrap_or(None)) - } - #[cfg(any( - not(feature = "plugin_framework"), - target_os = "android", - target_os = "ios" - ))] - { - SyncReturn(None) - } -} - -#[inline] -pub fn plugin_set_shared_option(_id: String, _key: String, _value: String) { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - allow_err!(crate::plugin::ipc::set_config(&_id, &_key, _value)); - } -} - -#[inline] -pub fn plugin_reload(_id: String) { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - allow_err!(crate::plugin::ipc::reload_plugin(&_id,)); - allow_err!(crate::plugin::reload_plugin(&_id)); - } -} - -#[inline] -pub fn plugin_enable(_id: String, _v: bool) -> SyncReturn<()> { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - allow_err!(crate::plugin::ipc::set_manager_plugin_config( - &_id, - "enabled", - _v.to_string() - )); - if _v { - allow_err!(crate::plugin::load_plugin(&_id)); - } else { - crate::plugin::unload_plugin(&_id); - } - } - SyncReturn(()) -} - -pub fn plugin_is_enabled(_id: String) -> SyncReturn { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - SyncReturn( - match crate::plugin::ipc::get_manager_plugin_config(&_id, "enabled") { - Ok(Some(enabled)) => bool::from_str(&enabled).unwrap_or(false), - _ => false, - }, - ) - } - #[cfg(any( - not(feature = "plugin_framework"), - target_os = "android", - target_os = "ios" - ))] - { - SyncReturn(false) - } -} - -pub fn plugin_feature_is_enabled() -> SyncReturn { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - #[cfg(debug_assertions)] - let enabled = true; - #[cfg(not(debug_assertions))] - let enabled = is_installed(); - SyncReturn(enabled) - } - #[cfg(any( - not(feature = "plugin_framework"), - target_os = "android", - target_os = "ios" - ))] - { - SyncReturn(false) - } -} - -pub fn plugin_sync_ui(_sync_to: String) { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - if plugin_feature_is_enabled().0 { - crate::plugin::sync_ui(_sync_to); - } - } -} - -pub fn plugin_list_reload() { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - crate::plugin::load_plugin_list(); - } -} - -pub fn plugin_install(_id: String, _b: bool) { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - if _b { - if let Err(e) = crate::plugin::install_plugin(&_id) { - log::error!("Failed to install plugin '{}': {}", _id, e); - } - } else { - crate::plugin::uninstall_plugin(&_id, true); - } - } -} - pub fn is_support_multi_ui_session(version: String) -> SyncReturn { SyncReturn(crate::common::is_support_multi_ui_session(&version)) } diff --git a/src/ipc.rs b/src/ipc.rs index 52e79955d..9e3faab63 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -19,9 +19,6 @@ pub(crate) use ipc_drm::DrmConn; #[cfg(all(target_os = "linux", feature = "drm"))] pub(crate) use ipc_drm::connect_drm; -#[cfg(all(feature = "flutter", feature = "plugin_framework"))] -#[cfg(not(any(target_os = "android", target_os = "ios")))] -use crate::plugin::ipc::Plugin; use crate::{ common::{is_server, CheckTestNatType}, privacy_mode, @@ -404,9 +401,6 @@ pub enum Data { StartVoiceCall, VoiceCallResponse(bool), CloseVoiceCall(String), - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - Plugin(Plugin), #[cfg(windows)] SyncWinCpuUsage(Option), FileTransferLog((String, String)), @@ -1076,9 +1070,6 @@ async fn handle(data: Data, stream: &mut Connection) { .await ); } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - Data::Plugin(plugin) => crate::plugin::ipc::handle_plugin(plugin, stream).await, #[cfg(windows)] Data::ControlledSessionCount(_) => { allow_err!( diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 2189648d9..bc9ea67d8 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "البصمة"), ("Copy Fingerprint", "نسخ البصمة"), ("no fingerprints", "لا توجد بصمات اصابع"), - ("Select a peer", "اختر قرين"), - ("Select peers", "اختر الاقران"), - ("Plugins", "الاضافات"), ("Uninstall", "الغاء التثبيت"), ("Update", "تحديث"), ("Enable", "تفعيل"), diff --git a/src/lang/be.rs b/src/lang/be.rs index ac302f3af..3dd0ef75e 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Адбітак"), ("Copy Fingerprint", "Капіяваць адбітак"), ("no fingerprints", "адбіткі адсутнічаюць"), - ("Select a peer", "Выберыце абанента"), - ("Select peers", "Выберыце абанентаў"), - ("Plugins", "Убудовы"), ("Uninstall", "Выдаліць"), ("Update", "Абнавіць"), ("Enable", "Уключыць"), diff --git a/src/lang/bg.rs b/src/lang/bg.rs index c339270c0..0d926db31 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Пръстов отпечатък"), ("Copy Fingerprint", "Копиране на пръстов отпечатък"), ("no fingerprints", "Няма пръстови отпечатъци"), - ("Select a peer", "Избери отдалечена страна"), - ("Select peers", "Избери отдалечени страни"), - ("Plugins", "Плъгини"), ("Uninstall", "Премахни"), ("Update", "Обновяване"), ("Enable", "Позволяване"), diff --git a/src/lang/ca.rs b/src/lang/ca.rs index d3b0ae7e0..17f5817b2 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Empremta"), ("Copy Fingerprint", "Copia l'empremta"), ("no fingerprints", "Cap empremta"), - ("Select a peer", "Seleccioneu un client"), - ("Select peers", "Seleccioneu els clients"), - ("Plugins", "Complements"), ("Uninstall", "Desinstal·la"), ("Update", "Actualitza"), ("Enable", "Activa"), diff --git a/src/lang/cn.rs b/src/lang/cn.rs index 7423cceb3..d1ada573b 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "指纹"), ("Copy Fingerprint", "复制指纹"), ("no fingerprints", "没有指纹"), - ("Select a peer", "选择一个被控端"), - ("Select peers", "选择被控"), - ("Plugins", "插件"), ("Uninstall", "卸载"), ("Update", "更新"), ("Enable", "启用"), diff --git a/src/lang/cs.rs b/src/lang/cs.rs index abd4e60aa..3bb59ecfb 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Otisk"), ("Copy Fingerprint", "Kopírovat otisk"), ("no fingerprints", "žádný otisk"), - ("Select a peer", "Výběr protistrany"), - ("Select peers", "Vybrat protistrany"), - ("Plugins", "Pluginy"), ("Uninstall", "Odinstalovat"), ("Update", "Aktualizovat"), ("Enable", "Povolit"), diff --git a/src/lang/da.rs b/src/lang/da.rs index 0ecab9098..823a58a6b 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingeraftryk"), ("Copy Fingerprint", "Kopiér fingeraftryk"), ("no fingerprints", "Ingen fingeraftryk"), - ("Select a peer", "Vælg en peer"), - ("Select peers", "Vælg peers"), - ("Plugins", "Plugins"), ("Uninstall", "Afinstallér"), ("Update", "Opdatér"), ("Enable", "Aktivér"), diff --git a/src/lang/de.rs b/src/lang/de.rs index d71dfa6ce..7f30d1051 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingerabdruck"), ("Copy Fingerprint", "Fingerabdruck kopieren"), ("no fingerprints", "Keine Fingerabdrücke"), - ("Select a peer", "Gegenstelle auswählen"), - ("Select peers", "Gegenstellen auswählen"), - ("Plugins", "Plugins"), ("Uninstall", "Deinstallieren"), ("Update", "Update"), ("Enable", "Aktivieren"), diff --git a/src/lang/el.rs b/src/lang/el.rs index deca79aa6..e6c4ab6fe 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Δακτυλικό αποτύπωμα"), ("Copy Fingerprint", "Αντιγραφή δακτυλικού αποτυπώματος"), ("no fingerprints", "χωρίς δακτυλικά αποτυπώματα"), - ("Select a peer", "Επιλέξτε έναν σταθμό"), - ("Select peers", "Επιλέξτε σταθμούς"), - ("Plugins", "Επεκτάσεις"), ("Uninstall", "Κατάργηση εγκατάστασης"), ("Update", "Ενημέρωση"), ("Enable", "Ενεργοποίηση"), diff --git a/src/lang/eo.rs b/src/lang/eo.rs index e6cc0cae5..5e5c19d64 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingrospuro"), ("Copy Fingerprint", "Kopii fingrospuron"), ("no fingerprints", "Neniuj fingrospuroj"), - ("Select a peer", "Elekti samulon"), - ("Select peers", "Elekti samulojn"), - ("Plugins", "Kromprogramoj"), ("Uninstall", "Malinstali"), ("Update", "Ĝisdatigi"), ("Enable", "Ebligi"), diff --git a/src/lang/es.rs b/src/lang/es.rs index 2e7ace9cf..7f12ef41e 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Huella digital"), ("Copy Fingerprint", "Copiar huella digital"), ("no fingerprints", "sin huellas digitales"), - ("Select a peer", "Seleccionar un par"), - ("Select peers", "Seleccionar pares"), - ("Plugins", "Complementos"), ("Uninstall", "Desinstalar"), ("Update", "Actualizar"), ("Enable", "Habilitar"), diff --git a/src/lang/et.rs b/src/lang/et.rs index 238c84c88..d8cd510bb 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Sõrmejälg"), ("Copy Fingerprint", "Kopeeri sõrmejälg"), ("no fingerprints", "Sõrmejäljed puuduvad"), - ("Select a peer", "Vali partner"), - ("Select peers", "Vali partnerid"), - ("Plugins", "Pluginad"), ("Uninstall", "Desinstalli"), ("Update", "Uuenda"), ("Enable", "Luba"), diff --git a/src/lang/eu.rs b/src/lang/eu.rs index 3fd38eb55..838b26353 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Hatz-marka"), ("Copy Fingerprint", "Kopiatu hatz-marka"), ("no fingerprints", "hatz-markarik ez"), - ("Select a peer", "Hautatu parekidea"), - ("Select peers", "Hautatu parekideak"), - ("Plugins", "Pluginak"), ("Uninstall", "Desinstalatu"), ("Update", "Eguneratu"), ("Enable", "Gaitu"), diff --git a/src/lang/fa.rs b/src/lang/fa.rs index 1e4039be7..08dc9f568 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "\n اثر انگشت"), ("Copy Fingerprint", "کپی کردن اثر انگشت"), ("no fingerprints", "بدون اثر انگشت"), - ("Select a peer", "یک همتا را انتخاب کنید"), - ("Select peers", "همتایان را انتخاب کنید"), - ("Plugins", "پلاگین ها"), ("Uninstall", "حذف نصب"), ("Update", "به روز رسانی"), ("Enable", "فعال کردن"), diff --git a/src/lang/fi.rs b/src/lang/fi.rs index 2a21ba049..d86cad0c7 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Sormenjälki"), ("Copy Fingerprint", "Kopioi sormenjälki"), ("no fingerprints", "Ei sormenjälkiä"), - ("Select a peer", "Valitse vastapää"), - ("Select peers", "Valitse useita vastapään laitteita"), - ("Plugins", "Laajennukset"), ("Uninstall", "Poista asennus"), ("Update", "Päivitä"), ("Enable", "Ota käyttöön"), diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 8359587a2..a20c0e41b 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Empreinte numérique"), ("Copy Fingerprint", "Copier l’empreinte numérique"), ("no fingerprints", "Aucune empreinte numérique"), - ("Select a peer", "Sélectionnez l’appareil distant"), - ("Select peers", "Sélectionnez les appareils distants"), - ("Plugins", "Plugins"), ("Uninstall", "Désinstaller"), ("Update", "Mettre à jour"), ("Enable", "Activer"), diff --git a/src/lang/ge.rs b/src/lang/ge.rs index 97c3e9171..ce6e89bb4 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ანაბეჭდი"), ("Copy Fingerprint", "ანაბეჭდის კოპირება"), ("no fingerprints", "ანაბეჭდები არ არის"), - ("Select a peer", "აირჩიეთ დისტანციური კვანძი"), - ("Select peers", "აირჩიეთ დისტანციური კვანძები"), - ("Plugins", "დანამატები"), ("Uninstall", "წაშლა"), ("Update", "განახლება"), ("Enable", "ჩართვა"), diff --git a/src/lang/gu.rs b/src/lang/gu.rs index c9c2c9177..31e905ea7 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ફિંગરપ્રિન્ટ"), ("Copy Fingerprint", "ફિંગરપ્રિન્ટ કોપી કરો"), ("no fingerprints", "કોઈ ફિંગરપ્રિન્ટ નથી"), - ("Select a peer", "એક પીઅર પસંદ કરો"), - ("Select peers", "પીઅર્સ પસંદ કરો"), - ("Plugins", "પ્લગઇન્સ"), ("Uninstall", "અનઇન્સ્ટોલ કરો"), ("Update", "અપડેટ કરો"), ("Enable", "સક્ષમ કરો"), diff --git a/src/lang/he.rs b/src/lang/he.rs index 3ea0d7626..b82f66dab 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "טביעת אצבע"), ("Copy Fingerprint", "העתק טביעת אצבע"), ("no fingerprints", "אין טביעות אצבע"), - ("Select a peer", "בחר עמית"), - ("Select peers", "בחר עמיתים"), - ("Plugins", "תוספים"), ("Uninstall", "הסר"), ("Update", "עדכן"), ("Enable", "פועל"), diff --git a/src/lang/hi.rs b/src/lang/hi.rs index e3851a0d8..1e5d3a0b5 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "फिंगरप्रिंट"), ("Copy Fingerprint", "फिंगरप्रिंट कॉपी करें"), ("no fingerprints", "कोई फिंगरप्रिंट नहीं"), - ("Select a peer", "एक पीयर (Peer) चुनें"), - ("Select peers", "पीयर्स चुनें"), - ("Plugins", "प्लगइन्स"), ("Uninstall", "अनइंस्टॉल करें"), ("Update", "अपडेट करें"), ("Enable", "सक्षम करें"), diff --git a/src/lang/hr.rs b/src/lang/hr.rs index ee894b0e7..a05462292 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Otisak"), ("Copy Fingerprint", "Kopirat otisak"), ("no fingerprints", "nema otiska"), - ("Select a peer", "Izbor druge strane"), - ("Select peers", "Odaberite druge strane"), - ("Plugins", "Dodaci"), ("Uninstall", "Deinstaliraj"), ("Update", "Ažuriraj"), ("Enable", "Dopustiti"), diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 14a85f1f7..68d26c389 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Ujjlenyomat"), ("Copy Fingerprint", "Ujjlenyomat másolása"), ("no fingerprints", "nincsenek ujjlenyomatok"), - ("Select a peer", "Egy távoli állomás kiválasztása"), - ("Select peers", "Távoli állomások kiválasztása"), - ("Plugins", "Beépülő modulok"), ("Uninstall", "Eltávolítás"), ("Update", "Frissítés"), ("Enable", "Engedélyezés"), diff --git a/src/lang/id.rs b/src/lang/id.rs index 7ba387e48..c51c908a4 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Sidik jari"), ("Copy Fingerprint", "Salin sidik jari"), ("no fingerprints", "Tidak ada sidik jari"), - ("Select a peer", "Pilih rekan"), - ("Select peers", "Pilih rekan-rekan"), - ("Plugins", "Plugin"), ("Uninstall", "Hapus instalasi"), ("Update", "Perbarui"), ("Enable", "Aktifkan"), diff --git a/src/lang/it.rs b/src/lang/it.rs index 1297972df..939048e3b 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Firma digitale"), ("Copy Fingerprint", "Copia firma digitale"), ("no fingerprints", "Nessuna firma digitale"), - ("Select a peer", "Seleziona dispositivo remoto"), - ("Select peers", "Seleziona dispositivi remoti"), - ("Plugins", "Plugin"), ("Uninstall", "Disinstalla"), ("Update", "Aggiorna"), ("Enable", "Abilita"), diff --git a/src/lang/ja.rs b/src/lang/ja.rs index ba6e6cb09..2d944bf8f 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "フィンガープリント"), ("Copy Fingerprint", "フィンガープリントをコピー"), ("no fingerprints", "フィンガープリントがありません"), - ("Select a peer", "リモートコンピューターを選択"), - ("Select peers", "複数のリモートコンピューターを選択"), - ("Plugins", "プラグイン"), ("Uninstall", "アンインストール"), ("Update", "更新"), ("Enable", "有効"), diff --git a/src/lang/ko.rs b/src/lang/ko.rs index f60af542b..d46c327d3 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "지문"), ("Copy Fingerprint", "지문 복사"), ("no fingerprints", "지문이 없습니다"), - ("Select a peer", "피어 선택"), - ("Select peers", "피어 선택"), - ("Plugins", "플러그인"), ("Uninstall", "설치 제거"), ("Update", "업데이트"), ("Enable", "허용"), diff --git a/src/lang/kz.rs b/src/lang/kz.rs index fc59efde3..b73b8dac0 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Саусақ ізі"), ("Copy Fingerprint", "Саусақ ізін көшіру"), ("no fingerprints", "Саусақ іздері жоқ"), - ("Select a peer", "Пир таңдау"), - ("Select peers", "Пирлерді таңдау"), - ("Plugins", "Плагиндер"), ("Uninstall", "Жою"), ("Update", "Жаңарту"), ("Enable", "Қосу"), diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 3589a2fb3..5c26e3119 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Kontrolinis kodas"), ("Copy Fingerprint", "Kopijuoti kontrolinį kodą"), ("no fingerprints", "Nėra kontrolinių kodų"), - ("Select a peer", "Pasirinkite įrenginį"), - ("Select peers", "Pasirinkite įrenginius"), - ("Plugins", "Papildiniai"), ("Uninstall", "Pašalinti"), ("Update", "Atnaujinti"), ("Enable", "Įgalinti"), diff --git a/src/lang/lv.rs b/src/lang/lv.rs index d4101d6db..7ae317893 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Pirkstu nospiedums"), ("Copy Fingerprint", "Kopēt pirkstu nospiedumu"), ("no fingerprints", "nav pirkstu nospiedumu"), - ("Select a peer", "Atlasīt līdzīgu"), - ("Select peers", "Atlasīt līdzīgus"), - ("Plugins", "Spraudņi"), ("Uninstall", "Atinstalēt"), ("Update", "Atjaunināt"), ("Enable", "Iespējot"), diff --git a/src/lang/ml.rs b/src/lang/ml.rs index d93760b50..69394909b 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ഫിംഗർപ്രിന്റ്"), ("Copy Fingerprint", "ഫിംഗർപ്രിന്റ് കോപ്പി ചെയ്യുക"), ("no fingerprints", "ഫിംഗർപ്രിന്റുകൾ ഇല്ല"), - ("Select a peer", "ഒരാളെ തിരഞ്ഞെടുക്കുക"), - ("Select peers", "തിരഞ്ഞെടുക്കുക"), - ("Plugins", "പ്ലഗിനുകൾ"), ("Uninstall", "അൺഇൻസ്റ്റാൾ ചെയ്യുക"), ("Update", "അപ്ഡേറ്റ് ചെയ്യുക"), ("Enable", "പ്രവർത്തനക്ഷമമാക്കുക"), diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 3cc71a96b..cf0009314 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingeravtrykk"), ("Copy Fingerprint", "Kopier fingeravtrykk"), ("no fingerprints", "Ingen fingeravtrykk"), - ("Select a peer", "Velg en motpart"), - ("Select peers", "Velg motparter"), - ("Plugins", "Programtillegg"), ("Uninstall", "Avinstaller"), ("Update", "Oppdater"), ("Enable", "Aktiver"), diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 61a5306c9..68206f0d6 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Vingerafdruk"), ("Copy Fingerprint", "Vingerafdruk kopiëren"), ("no fingerprints", "geen vingerafdrukken"), - ("Select a peer", "Selecteer een peer"), - ("Select peers", "Selecteer peers"), - ("Plugins", "Plugins"), ("Uninstall", "Verwijderen"), ("Update", "Bijwerken"), ("Enable", "Activeren"), diff --git a/src/lang/pl.rs b/src/lang/pl.rs index df5c53439..0e2e03f02 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Sygnatura"), ("Copy Fingerprint", "Skopiuj sygnaturę"), ("no fingerprints", "brak sygnatur"), - ("Select a peer", "Wybierz zdalne urządzenie"), - ("Select peers", "Wybierz zdalne urządzenia"), - ("Plugins", "Wtyczki"), ("Uninstall", "Odinstaluj"), ("Update", "Aktualizuj"), ("Enable", "Włącz"), diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index 79420e73b..a391fcfdc 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Impressão digital"), ("Copy Fingerprint", "Copiar impressão digital"), ("no fingerprints", "Sem impressões digitais"), - ("Select a peer", "Selecionar um destino"), - ("Select peers", "Selecionar destinos"), - ("Plugins", "Plugins"), ("Uninstall", "Desinstalar"), ("Update", "Atualizar"), ("Enable", "Ativar"), diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 61bf5cf48..522b3f8b8 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Impressão Digital"), ("Copy Fingerprint", "Copiar Impressão Digital"), ("no fingerprints", "sem Impressões Digitais"), - ("Select a peer", "Selecione um parceiro"), - ("Select peers", "Selecione parceiros"), - ("Plugins", "Plugins"), ("Uninstall", "Desinstalar"), ("Update", "Atualizar"), ("Enable", "Habilitar"), diff --git a/src/lang/ro.rs b/src/lang/ro.rs index 4499df1bd..cc774057e 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Amprentă digitală"), ("Copy Fingerprint", "Copiază amprenta digitală"), ("no fingerprints", "Nicio amprentă digitală"), - ("Select a peer", "Selectează un dispozitiv pereche"), - ("Select peers", "Selectează dispozitive pereche"), - ("Plugins", "Pluginuri"), ("Uninstall", "Dezinstalează"), ("Update", "Actualizează"), ("Enable", "Activează"), diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 459549f97..2ded4ebf5 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Отпечаток"), ("Copy Fingerprint", "Копировать отпечаток"), ("no fingerprints", "отпечатки отсутствуют"), - ("Select a peer", "Выберите удалённый узел"), - ("Select peers", "Выберите удалённые узлы"), - ("Plugins", "Плагины"), ("Uninstall", "Удалить"), ("Update", "Обновить"), ("Enable", "Включить"), diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 1ccfcf7dc..6bb1190c1 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Firma digitale"), ("Copy Fingerprint", "Còpia firma digitale"), ("no fingerprints", "Peruna firma digitale"), - ("Select a peer", "Seletziona su dispositivu remotu"), - ("Select peers", "Seletziona sos dispositivos remotos"), - ("Plugins", "Cumplementos"), ("Uninstall", "Disinstalla"), ("Update", "Atualiza"), ("Enable", "Abìlita"), diff --git a/src/lang/sk.rs b/src/lang/sk.rs index 3d4993115..96eb423ef 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Odtlačok prsta"), ("Copy Fingerprint", "Kopírovať odtlačok prsta"), ("no fingerprints", "žiadne odtlačky prstov"), - ("Select a peer", "Výber partnera"), - ("Select peers", "Výber partnerov"), - ("Plugins", "Pluginy"), ("Uninstall", "Odinštalovať"), ("Update", "Aktualizovať"), ("Enable", "Povoliť"), diff --git a/src/lang/sl.rs b/src/lang/sl.rs index 10fc5d909..82f177428 100755 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Prstni odtis"), ("Copy Fingerprint", "Kopiraj prstni odtis"), ("no fingerprints", "ni prstnega odtisa"), - ("Select a peer", "Izberite partnerja"), - ("Select peers", "Izberite partnerje"), - ("Plugins", "Vključki"), ("Uninstall", "Odstrani"), ("Update", "Posodobi"), ("Enable", "Omogoči"), diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 91f5d4c7a..103a1bfe9 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Gjurma e gishtit"), ("Copy Fingerprint", "Kopjo gjurmën e gishtit"), ("no fingerprints", "Nuk ka gjurmë gishtash"), - ("Select a peer", "Zgjidh një peer"), - ("Select peers", "Zgjidh peer-at"), - ("Plugins", "Shtojcat"), ("Uninstall", "Çinstalo"), ("Update", "Përditëso"), ("Enable", "Aktivizo"), diff --git a/src/lang/sr.rs b/src/lang/sr.rs index b79eccf5b..c58f7b174 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Otisak"), ("Copy Fingerprint", "Kopiraj otisak"), ("no fingerprints", "Nema otisaka"), - ("Select a peer", "Izaberi klijenta"), - ("Select peers", "Izaberi klijente"), - ("Plugins", "Dodaci"), ("Uninstall", "Deinstaliraj"), ("Update", "Ažuriraj"), ("Enable", "Omogući"), diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 79dd316cd..5075bd6d4 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingeravtryck"), ("Copy Fingerprint", "Kopiera fingeravtryck"), ("no fingerprints", "inga fingeravtryck"), - ("Select a peer", "Välj en klient"), - ("Select peers", "Välj klienter"), - ("Plugins", "Plugin"), ("Uninstall", "Avinstallera"), ("Update", "Uppdatera"), ("Enable", "Aktivera"), diff --git a/src/lang/ta.rs b/src/lang/ta.rs index 376af972e..37af3a97d 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "கைரேகை"), ("Copy Fingerprint", "கைரேகை நகல்"), ("no fingerprints", "கைரேகைகள் இல்லை"), - ("Select a peer", "பியர் தேர்வு"), - ("Select peers", "பியர்கள் தேர்வு"), - ("Plugins", "இணைப்புகள்"), ("Uninstall", "நிறுவல் நீக்கு"), ("Update", "புதுப்பி"), ("Enable", "இயக்கு"), diff --git a/src/lang/template.rs b/src/lang/template.rs index f16cf1ebc..e31425369 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", ""), ("Copy Fingerprint", ""), ("no fingerprints", ""), - ("Select a peer", ""), - ("Select peers", ""), - ("Plugins", ""), ("Uninstall", ""), ("Update", ""), ("Enable", ""), diff --git a/src/lang/th.rs b/src/lang/th.rs index bd87cf5a7..8e2c33ebb 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ลายนิ้วมือ"), ("Copy Fingerprint", "คัดลอกลายนิ้วมือ"), ("no fingerprints", "ไม่มีลายนิ้วมือ"), - ("Select a peer", "เลือกผู้ใช้งาน"), - ("Select peers", "เลือกผู้ใช้งาน"), - ("Plugins", "ปลั๊กอิน"), ("Uninstall", "ถอนการติดตั้ง"), ("Update", "อัปเดต"), ("Enable", "เปิดใช้งาน"), diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 2925ce792..f548e39de 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Parmak İzi"), ("Copy Fingerprint", "Parmak İzini Kopyala"), ("no fingerprints", "parmak izi yok"), - ("Select a peer", "Bir cihaz seçin"), - ("Select peers", "Cihazları seçin"), - ("Plugins", "Eklentiler"), ("Uninstall", "Kaldır"), ("Update", "Güncelle"), ("Enable", "Etkinleştir"), diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 438cb8091..6e22e4d79 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "指紋"), ("Copy Fingerprint", "複製指紋"), ("no fingerprints", "沒有指紋"), - ("Select a peer", "選擇夥伴"), - ("Select peers", "選擇夥伴"), - ("Plugins", "外掛程式"), ("Uninstall", "解除安裝"), ("Update", "更新"), ("Enable", "啟用"), diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 7e55426d1..f03bcb089 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Відбитки пальців"), ("Copy Fingerprint", "Копіювати відбитки пальців"), ("no fingerprints", "немає відбитків пальців"), - ("Select a peer", "Оберіть віддалений пристрій"), - ("Select peers", "Оберіть віддалені пристрої"), - ("Plugins", "Плагіни"), ("Uninstall", "Видалити"), ("Update", "Оновити"), ("Enable", "Увімкнути"), diff --git a/src/lang/vi.rs b/src/lang/vi.rs index af358831e..0b9421ba4 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Dấu vân tay"), ("Copy Fingerprint", "Sao chép fingerprint"), ("no fingerprints", "không có fingerprint"), - ("Select a peer", "Chọn một đối tác"), - ("Select peers", "Chọn các đối tác"), - ("Plugins", "Plugin"), ("Uninstall", "Gỡ cài đặt"), ("Update", "Cập nhật"), ("Enable", "Bật"), diff --git a/src/lib.rs b/src/lib.rs index 49cb2b7e9..20d5d6aab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,10 +46,6 @@ mod lang; #[cfg(not(any(target_os = "android", target_os = "ios")))] mod port_forward; -#[cfg(all(feature = "flutter", feature = "plugin_framework"))] -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub mod plugin; - #[cfg(not(any(target_os = "android", target_os = "ios")))] mod tray; diff --git a/src/plugin/callback_ext.rs b/src/plugin/callback_ext.rs deleted file mode 100644 index 715f47f7e..000000000 --- a/src/plugin/callback_ext.rs +++ /dev/null @@ -1,44 +0,0 @@ -// External support for callback. -// 1. Support block input for some plugins. -// ----------------------------------------------------------------------------- - -use super::*; - -const EXT_SUPPORT_BLOCK_INPUT: &str = "block-input"; - -pub(super) fn ext_support_callback( - id: &str, - peer: &str, - msg: &super::callback_msg::MsgToExtSupport, -) -> PluginReturn { - match &msg.r#type as _ { - EXT_SUPPORT_BLOCK_INPUT => { - // let supported_plugins = []; - // let supported = supported_plugins.contains(&id); - let supported = true; - if supported { - if msg.data.len() != 1 { - return PluginReturn::new( - errno::ERR_CALLBACK_INVALID_ARGS, - "Invalid data length", - ); - } - let block = msg.data[0] != 0; - if crate::server::plugin_block_input(peer, block) == block { - PluginReturn::success() - } else { - PluginReturn::new(errno::ERR_CALLBACK_FAILED, "") - } - } else { - PluginReturn::new( - errno::ERR_CALLBACK_PLUGIN_ID, - &format!("This operation is not supported for plugin '{}', please contact the RustDesk team for support.", id), - ) - } - } - _ => PluginReturn::new( - errno::ERR_CALLBACK_TARGET_TYPE, - &format!("Unknown target type '{}'", &msg.r#type), - ), - } -} diff --git a/src/plugin/callback_msg.rs b/src/plugin/callback_msg.rs deleted file mode 100644 index 2a23b03dd..000000000 --- a/src/plugin/callback_msg.rs +++ /dev/null @@ -1,411 +0,0 @@ -use super::*; -use crate::hbbs_http::create_http_client; -use crate::{ - flutter::{self, APP_TYPE_CM, APP_TYPE_MAIN, SESSIONS}, - ui_interface::get_api_server, -}; -use hbb_common::{lazy_static, log, message_proto::PluginRequest}; -use serde_derive::{Deserialize, Serialize}; -use serde_json; -use std::{ - collections::HashMap, - ffi::{c_char, c_void}, - sync::Arc, - thread, - time::Duration, -}; - -const MSG_TO_RUSTDESK_TARGET: &str = "rustdesk"; -const MSG_TO_PEER_TARGET: &str = "peer"; -const MSG_TO_UI_TARGET: &str = "ui"; -const MSG_TO_CONFIG_TARGET: &str = "config"; -const MSG_TO_EXT_SUPPORT_TARGET: &str = "ext-support"; - -const MSG_TO_RUSTDESK_SIGNATURE_VERIFICATION: &str = "signature_verification"; - -#[allow(dead_code)] -const MSG_TO_UI_FLUTTER_CHANNEL_MAIN: u16 = 0x01 << 0; -#[allow(dead_code)] -#[cfg(not(any(target_os = "android", target_os = "ios")))] -const MSG_TO_UI_FLUTTER_CHANNEL_CM: u16 = 0x01 << 1; -#[cfg(any(target_os = "android", target_os = "ios"))] -const MSG_TO_UI_FLUTTER_CHANNEL_CM: u16 = 0x01; -const MSG_TO_UI_FLUTTER_CHANNEL_REMOTE: u16 = 0x01 << 2; -#[allow(dead_code)] -const MSG_TO_UI_FLUTTER_CHANNEL_TRANSFER: u16 = 0x01 << 3; -#[allow(dead_code)] -const MSG_TO_UI_FLUTTER_CHANNEL_FORWARD: u16 = 0x01 << 4; - -lazy_static::lazy_static! { - static ref MSG_TO_UI_FLUTTER_CHANNELS: Arc> = { - let channels = HashMap::from([ - (MSG_TO_UI_FLUTTER_CHANNEL_MAIN, APP_TYPE_MAIN.to_string()), - (MSG_TO_UI_FLUTTER_CHANNEL_CM, APP_TYPE_CM.to_string()), - ]); - Arc::new(channels) - }; -} - -#[derive(Deserialize)] -pub struct MsgToRustDesk { - pub r#type: String, - pub data: Vec, -} - -#[derive(Deserialize)] -pub struct SignatureVerification { - pub version: String, - pub data: Vec, -} - -#[derive(Debug, Deserialize)] -struct ConfigToUi { - channel: u16, - location: String, -} - -#[derive(Debug, Deserialize)] -struct MsgToConfig { - r#type: String, - key: String, - value: String, - #[serde(skip_serializing_if = "Option::is_none")] - ui: Option, // If not None, send msg to ui. -} - -#[derive(Debug, Deserialize)] -pub(super) struct MsgToExtSupport { - pub r#type: String, - pub data: Vec, -} - -#[derive(Debug, Serialize)] -struct PluginSignReq { - plugin_id: String, - version: String, - msg: Vec, -} - -#[derive(Debug, Deserialize)] -struct PluginSignResp { - signed_msg: Vec, -} - -macro_rules! cb_msg_field { - ($field: ident) => { - let $field = match cstr_to_string($field) { - Err(e) => { - let msg = format!("Failed to convert {} to string, {}", stringify!($field), e); - log::error!("{}", &msg); - return PluginReturn::new(errno::ERR_CALLBACK_INVALID_ARGS, &msg); - } - Ok(v) => v, - }; - }; -} - -macro_rules! early_return_value { - ($e:expr, $code: ident, $($arg:tt)*) => { - match $e { - Err(e) => return PluginReturn::new( - errno::$code, - &format!("Failed to {} '{}'", format_args!($($arg)*), e), - ), - Ok(v) => v, - } - }; -} - -/// Callback to send message to peer or ui. -/// peer, target, id are utf8 strings(null terminated). -/// -/// peer: The peer id. -/// target: "peer" or "ui". -/// id: The id of this plugin. -/// content: The content. -/// len: The length of the content. -/// -/// Return null ptr if success. -/// Return the error message if failed. `i32-String` without dash, i32 is a signed little-endian number, the String is utf8 string. -/// The plugin allocate memory with `libc::malloc` and return the pointer. -#[no_mangle] -pub(super) extern "C" fn cb_msg( - peer: *const c_char, - target: *const c_char, - id: *const c_char, - content: *const c_void, - len: usize, -) -> PluginReturn { - cb_msg_field!(target); - cb_msg_field!(id); - - match &target as _ { - MSG_TO_PEER_TARGET => { - cb_msg_field!(peer); - if let Some(session) = SESSIONS.write().unwrap().get_mut(&peer) { - let content_slice = - unsafe { std::slice::from_raw_parts(content as *const u8, len) }; - let content_vec = Vec::from(content_slice); - let request = PluginRequest { - id, - content: bytes::Bytes::from(content_vec), - ..Default::default() - }; - session.send_plugin_request(request); - PluginReturn::success() - } else { - PluginReturn::new( - errno::ERR_CALLBACK_PEER_NOT_FOUND, - &format!("Failed to find session for peer '{}'", peer), - ) - } - } - MSG_TO_UI_TARGET => { - cb_msg_field!(peer); - let content_slice = unsafe { std::slice::from_raw_parts(content as *const u8, len) }; - let channel = u16::from_le_bytes([content_slice[0], content_slice[1]]); - let content = std::string::String::from_utf8(content_slice[2..].to_vec()) - .unwrap_or("".to_string()); - push_event_to_ui(channel, &peer, &content); - PluginReturn::success() - } - MSG_TO_CONFIG_TARGET => { - cb_msg_field!(peer); - let s = early_return_value!( - std::str::from_utf8(unsafe { std::slice::from_raw_parts(content as _, len) }), - ERR_CALLBACK_INVALID_MSG, - "parse msg string" - ); - // No need to merge the msgs. Handling the msg one by one is ok. - let msg = early_return_value!( - serde_json::from_str::(s), - ERR_CALLBACK_INVALID_MSG, - "parse msg '{}'", - s - ); - match &msg.r#type as _ { - config::CONFIG_TYPE_SHARED => { - let _r = early_return_value!( - config::SharedConfig::set(&id, &msg.key, &msg.value), - ERR_CALLBACK_INVALID_MSG, - "set local config" - ); - if let Some(ui) = &msg.ui { - // No need to set the peer id for location config. - push_option_to_ui(ui.channel, &id, "", &msg, ui); - } - PluginReturn::success() - } - config::CONFIG_TYPE_PEER => { - let _r = early_return_value!( - config::PeerConfig::set(&id, &peer, &msg.key, &msg.value), - ERR_CALLBACK_INVALID_MSG, - "set peer config" - ); - if let Some(ui) = &msg.ui { - push_option_to_ui(ui.channel, &id, &peer, &msg, ui); - } - PluginReturn::success() - } - _ => PluginReturn::new( - errno::ERR_CALLBACK_TARGET_TYPE, - &format!("Unknown target type '{}'", &msg.r#type), - ), - } - } - MSG_TO_EXT_SUPPORT_TARGET => { - cb_msg_field!(peer); - let s = early_return_value!( - std::str::from_utf8(unsafe { std::slice::from_raw_parts(content as _, len) }), - ERR_CALLBACK_INVALID_MSG, - "parse msg string" - ); - let msg = early_return_value!( - serde_json::from_str::(s), - ERR_CALLBACK_INVALID_MSG, - "parse msg '{}'", - s - ); - super::callback_ext::ext_support_callback(&id, &peer, &msg) - } - MSG_TO_RUSTDESK_TARGET => handle_msg_to_rustdesk(id, content, len), - _ => PluginReturn::new( - errno::ERR_CALLBACK_TARGET, - &format!("Unknown target '{}'", target), - ), - } -} - -#[inline] -fn is_peer_channel(channel: u16) -> bool { - channel & MSG_TO_UI_FLUTTER_CHANNEL_REMOTE != 0 - || channel & MSG_TO_UI_FLUTTER_CHANNEL_TRANSFER != 0 - || channel & MSG_TO_UI_FLUTTER_CHANNEL_FORWARD != 0 -} - -fn handle_msg_to_rustdesk(id: String, content: *const c_void, len: usize) -> PluginReturn { - let s = early_return_value!( - std::str::from_utf8(unsafe { std::slice::from_raw_parts(content as _, len) }), - ERR_CALLBACK_INVALID_MSG, - "parse msg string" - ); - let msg_to_rustdesk = early_return_value!( - serde_json::from_str::(s), - ERR_CALLBACK_INVALID_MSG, - "parse msg '{}'", - s - ); - match &msg_to_rustdesk.r#type as &str { - MSG_TO_RUSTDESK_SIGNATURE_VERIFICATION => request_plugin_sign(id, msg_to_rustdesk), - t => PluginReturn::new( - errno::ERR_CALLBACK_TARGET_TYPE, - &format!( - "Unknown target type '{}' for target {}", - t, MSG_TO_RUSTDESK_TARGET - ), - ), - } -} - -fn request_plugin_sign(id: String, msg_to_rustdesk: MsgToRustDesk) -> PluginReturn { - let signature_data = early_return_value!( - std::str::from_utf8(&msg_to_rustdesk.data), - ERR_CALLBACK_INVALID_MSG, - "parse signature data string" - ); - let signature_data = early_return_value!( - serde_json::from_str::(signature_data), - ERR_CALLBACK_INVALID_MSG, - "parse signature data '{}'", - signature_data - ); - thread::spawn(move || { - let sign_url = format!("{}/lic/web/api/plugin-sign", get_api_server()); - let client = create_http_client(); - let req = PluginSignReq { - plugin_id: id.clone(), - version: signature_data.version, - msg: signature_data.data, - }; - match client - .post(sign_url) - .json(&req) - .timeout(Duration::from_secs(10)) - .send() - { - Ok(response) => match response.json::() { - Ok(sign_resp) => { - match super::plugins::plugin_call( - &id, - super::plugins::METHOD_HANDLE_SIGNATURE_VERIFICATION, - "", - &sign_resp.signed_msg, - ) { - Ok(..) => { - match super::plugins::plugin_call_get_return( - &id, - super::plugins::METHOD_HANDLE_STATUS, - "", - &[], - ) { - Ok(ret) => { - debug_assert!(!ret.msg.is_null(), "msg is null"); - if ret.msg.is_null() { - // unreachable - log::error!( - "The returned message pointer of plugin status is null, plugin id: '{}', code: {}", - id, - ret.code, - ); - return; - } - let msg = cstr_to_string(ret.msg).unwrap_or_default(); - free_c_ptr(ret.msg as _); - if ret.code == super::errno::ERR_SUCCESS { - log::info!("Plugin '{}' status: '{}'", id, msg); - } else { - log::error!( - "Failed to handle plugin event, id: {}, method: {}, code: {}, msg: {}", - id, - std::string::String::from_utf8(super::plugins::METHOD_HANDLE_STATUS.to_vec()).unwrap_or_default(), - ret.code, - msg - ); - } - } - Err(e) => { - log::error!( - "Failed to call status for plugin '{}': {}", - &id, - e - ); - } - } - } - Err(e) => { - log::error!( - "Failed to call signature verification for plugin '{}': {}", - &id, - e - ); - } - } - } - Err(e) => { - log::error!("Failed to decode response for plugin '{}': {}", &id, e); - } - }, - Err(e) => { - log::error!("Failed to request sign for plugin '{}', {}", &id, e); - } - } - }); - PluginReturn::success() -} - -fn push_event_to_ui(channel: u16, peer: &str, content: &str) { - let mut m = HashMap::new(); - m.insert("name", MSG_TO_UI_TYPE_PLUGIN_EVENT); - m.insert("peer", &peer); - m.insert("content", &content); - let event = serde_json::to_string(&m).unwrap_or("".to_string()); - // Send to main and cm - for (k, v) in MSG_TO_UI_FLUTTER_CHANNELS.iter() { - if channel & k != 0 { - let _res = flutter::push_global_event(v as _, event.to_string()); - } - } - if !peer.is_empty() && is_peer_channel(channel) { - let _res = flutter::push_session_event( - &peer, - MSG_TO_UI_TYPE_PLUGIN_EVENT, - vec![("peer", &peer), ("content", &content)], - ); - } -} - -fn push_option_to_ui(channel: u16, id: &str, peer: &str, msg: &MsgToConfig, ui: &ConfigToUi) { - let v = [ - ("id", id), - ("location", &ui.location), - ("key", &msg.key), - ("value", &msg.value), - ]; - - // Send main and cm - let mut m = HashMap::from(v); - m.insert("name", MSG_TO_UI_TYPE_PLUGIN_OPTION); - let event = serde_json::to_string(&m).unwrap_or("".to_string()); - for (k, v) in MSG_TO_UI_FLUTTER_CHANNELS.iter() { - if channel & k != 0 { - let _res = flutter::push_global_event(v as _, event.to_string()); - } - } - - // Send remote, transfer and forward - if !peer.is_empty() && is_peer_channel(channel) { - let mut v = v.to_vec(); - v.push(("peer", &peer)); - let _res = flutter::push_session_event(&peer, MSG_TO_UI_TYPE_PLUGIN_OPTION, v); - } -} diff --git a/src/plugin/config.rs b/src/plugin/config.rs deleted file mode 100644 index 20cd02a88..000000000 --- a/src/plugin/config.rs +++ /dev/null @@ -1,363 +0,0 @@ -use super::{cstr_to_string, str_to_cstr_ret}; -use hbb_common::{allow_err, bail, config::Config as HbbConfig, lazy_static, log, ResultType}; -use serde_derive::{Deserialize, Serialize}; -use std::{ - collections::HashMap, - ffi::c_char, - fs, - ops::{Deref, DerefMut}, - path::PathBuf, - ptr, - str::FromStr, - sync::{Arc, Mutex}, -}; - -lazy_static::lazy_static! { - static ref CONFIG_SHARED: Arc>> = Default::default(); - static ref CONFIG_PEERS: Arc>> = Default::default(); - static ref CONFIG_MANAGER: Arc> = { - let conf = hbb_common::config::load_path::(ManagerConfig::path()); - Arc::new(Mutex::new(conf)) - }; -} -use crate::ui_interface::get_id; - -pub(super) const CONFIG_TYPE_SHARED: &str = "shared"; -pub(super) const CONFIG_TYPE_PEER: &str = "peer"; - -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct SharedConfig(HashMap); -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct PeerConfig(HashMap); -type PeersConfig = HashMap; - -#[inline] -fn path_plugins(id: &str) -> PathBuf { - HbbConfig::path("plugins").join(id) -} - -pub fn remove(id: &str) { - CONFIG_SHARED.lock().unwrap().remove(id); - CONFIG_PEERS.lock().unwrap().remove(id); - // allow_err is Ok here. - allow_err!(ManagerConfig::remove_plugin(id)); - if let Err(e) = fs::remove_dir_all(path_plugins(id)) { - log::error!("Failed to remove plugin '{}' directory: {}", id, e); - } -} - -impl Deref for SharedConfig { - type Target = HashMap; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for SharedConfig { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl Deref for PeerConfig { - type Target = HashMap; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for PeerConfig { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl SharedConfig { - #[inline] - fn path(id: &str) -> PathBuf { - path_plugins(id).join("shared.toml") - } - - #[inline] - fn load(id: &str) { - let mut lock = CONFIG_SHARED.lock().unwrap(); - if lock.contains_key(id) { - return; - } - let conf = hbb_common::config::load_path::>(Self::path(id)); - let mut conf = SharedConfig(conf); - if let Some(desc_conf) = super::plugins::get_desc_conf(id) { - for item in desc_conf.shared.iter() { - if !conf.contains_key(&item.key) { - conf.insert(item.key.to_owned(), item.default.to_owned()); - } - } - } - lock.insert(id.to_owned(), conf); - } - - #[inline] - fn load_if_not_exists(id: &str) { - if CONFIG_SHARED.lock().unwrap().contains_key(id) { - return; - } - Self::load(id); - } - - #[inline] - pub fn get(id: &str, key: &str) -> Option { - Self::load_if_not_exists(id); - CONFIG_SHARED - .lock() - .unwrap() - .get(id)? - .get(key) - .map(|s| s.to_owned()) - } - - #[inline] - pub fn set(id: &str, key: &str, value: &str) -> ResultType<()> { - Self::load_if_not_exists(id); - match CONFIG_SHARED.lock().unwrap().get_mut(id) { - Some(config) => { - config.insert(key.to_owned(), value.to_owned()); - hbb_common::config::store_path(Self::path(id), config) - } - None => { - // unreachable - bail!("No such plugin {}", id) - } - } - } -} - -impl PeerConfig { - #[inline] - fn path(id: &str, peer: &str) -> PathBuf { - path_plugins(id) - .join("peers") - .join(format!("{}.toml", peer)) - } - - #[inline] - fn load(id: &str, peer: &str) { - let mut lock = CONFIG_PEERS.lock().unwrap(); - if let Some(peers) = lock.get(id) { - if peers.contains_key(peer) { - return; - } - } - - let conf = hbb_common::config::load_path::>(Self::path(id, peer)); - let mut conf = PeerConfig(conf); - if let Some(desc_conf) = super::plugins::get_desc_conf(id) { - for item in desc_conf.peer.iter() { - if !conf.contains_key(&item.key) { - conf.insert(item.key.to_owned(), item.default.to_owned()); - } - } - } - - if let Some(peers) = lock.get_mut(id) { - peers.insert(peer.to_owned(), conf); - return; - } - - let mut peers = HashMap::new(); - peers.insert(peer.to_owned(), conf); - lock.insert(id.to_owned(), peers); - } - - #[inline] - fn load_if_not_exists(id: &str, peer: &str) { - if let Some(peers) = CONFIG_PEERS.lock().unwrap().get(id) { - if peers.contains_key(peer) { - return; - } - } - Self::load(id, peer); - } - - #[inline] - pub fn get(id: &str, peer: &str, key: &str) -> Option { - Self::load_if_not_exists(id, peer); - CONFIG_PEERS - .lock() - .unwrap() - .get(id)? - .get(peer)? - .get(key) - .map(|s| s.to_owned()) - } - - #[inline] - pub fn set(id: &str, peer: &str, key: &str, value: &str) -> ResultType<()> { - Self::load_if_not_exists(id, peer); - match CONFIG_PEERS.lock().unwrap().get_mut(id) { - Some(peers) => match peers.get_mut(peer) { - Some(config) => { - config.insert(key.to_owned(), value.to_owned()); - hbb_common::config::store_path(Self::path(id, peer), config) - } - None => { - // unreachable - bail!("No such peer {}", peer) - } - }, - None => { - // unreachable - bail!("No such plugin {}", id) - } - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct PluginStatus { - pub enabled: bool, -} - -const MANAGER_VERSION: &str = "0.1.0"; - -#[derive(Debug, Serialize, Deserialize)] -pub struct ManagerConfig { - pub version: String, - #[serde(default)] - pub options: HashMap, - #[serde(default)] - pub plugins: HashMap, -} - -impl Default for ManagerConfig { - fn default() -> Self { - Self { - version: MANAGER_VERSION.to_owned(), - options: HashMap::new(), - plugins: HashMap::new(), - } - } -} - -// Do not care about the `store_path` error, no need to store the old value and restore if failed. -impl ManagerConfig { - #[inline] - fn path() -> PathBuf { - HbbConfig::path("plugins").join("manager.toml") - } - - #[inline] - pub fn get_option(key: &str) -> Option { - CONFIG_MANAGER - .lock() - .unwrap() - .options - .get(key) - .map(|s| s.to_owned()) - } - - #[inline] - pub fn set_option(key: &str, value: &str) { - let mut lock = CONFIG_MANAGER.lock().unwrap(); - lock.options.insert(key.to_owned(), value.to_owned()); - allow_err!(hbb_common::config::store_path(Self::path(), &*lock)); - } - - #[inline] - pub fn get_plugin_option(id: &str, key: &str) -> Option { - let lock = CONFIG_MANAGER.lock().unwrap(); - match key { - "enabled" => { - let enabled = lock - .plugins - .get(id) - .map(|status| status.enabled.to_owned()) - .unwrap_or(true.to_owned()) - .to_string(); - Some(enabled) - } - _ => None, - } - } - - fn set_plugin_option_enabled(id: &str, enabled: bool) -> ResultType<()> { - let mut lock = CONFIG_MANAGER.lock().unwrap(); - if let Some(status) = lock.plugins.get_mut(id) { - status.enabled = enabled; - } else { - lock.plugins.insert(id.to_owned(), PluginStatus { enabled }); - } - hbb_common::config::store_path(Self::path(), &*lock) - } - - pub fn set_plugin_option(id: &str, key: &str, value: &str) { - match key { - "enabled" => { - let enabled = bool::from_str(value).unwrap_or(false); - allow_err!(Self::set_plugin_option_enabled(id, enabled)); - if enabled { - allow_err!(super::load_plugin(id)); - } else { - super::unload_plugin(id); - } - } - _ => log::error!("No such option {}", key), - } - } - - #[inline] - pub fn add_plugin(id: &str) -> ResultType<()> { - let mut lock = CONFIG_MANAGER.lock().unwrap(); - lock.plugins - .insert(id.to_owned(), PluginStatus { enabled: true }); - hbb_common::config::store_path(Self::path(), &*lock) - } - - #[inline] - pub fn remove_plugin(id: &str) -> ResultType<()> { - let mut lock = CONFIG_MANAGER.lock().unwrap(); - lock.plugins.remove(id); - hbb_common::config::store_path(Self::path(), &*lock) - } -} - -pub(super) extern "C" fn cb_get_local_peer_id() -> *const c_char { - str_to_cstr_ret(&get_id()) -} - -// Return shared config if peer is nullptr. -pub(super) extern "C" fn cb_get_conf( - peer: *const c_char, - id: *const c_char, - key: *const c_char, -) -> *const c_char { - match (cstr_to_string(id), cstr_to_string(key)) { - (Ok(id), Ok(key)) => { - if peer.is_null() { - SharedConfig::load_if_not_exists(&id); - if let Some(conf) = CONFIG_SHARED.lock().unwrap().get(&id) { - if let Some(value) = conf.get(&key) { - return str_to_cstr_ret(value); - } - } - } else { - match cstr_to_string(peer) { - Ok(peer) => { - PeerConfig::load_if_not_exists(&id, &peer); - if let Some(conf) = CONFIG_PEERS.lock().unwrap().get(&id) { - if let Some(conf) = conf.get(&peer) { - if let Some(value) = conf.get(&key) { - return str_to_cstr_ret(value); - } - } - } - } - Err(_) => {} - } - } - } - _ => {} - } - ptr::null() -} diff --git a/src/plugin/desc.rs b/src/plugin/desc.rs deleted file mode 100644 index 883f2afd7..000000000 --- a/src/plugin/desc.rs +++ /dev/null @@ -1,100 +0,0 @@ -use hbb_common::ResultType; -use serde_derive::{Deserialize, Serialize}; -use serde_json; -use std::collections::HashMap; -use std::ffi::{c_char, CStr}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UiButton { - key: String, - text: String, - icon: String, // icon can be int in flutter, but string in other ui framework. And it is flexible to use string. - tooltip: String, - action: String, // The action to be triggered when the button is clicked. -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UiCheckbox { - key: String, - text: String, - tooltip: String, - action: String, // The action to be triggered when the checkbox is checked or unchecked. -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "t", content = "c")] -pub enum UiType { - Button(UiButton), - Checkbox(UiCheckbox), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Location { - pub ui: HashMap>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConfigItem { - pub key: String, - pub default: String, - pub description: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Config { - pub shared: Vec, - pub peer: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PublishInfo { - pub published: String, - pub last_released: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Meta { - pub id: String, - pub name: String, - pub version: String, - pub description: String, - #[serde(default)] - pub platforms: String, - pub author: String, - pub home: String, - pub license: String, - pub source: String, - pub publish_info: PublishInfo, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Desc { - meta: Meta, - need_reboot: bool, - location: Location, - config: Config, - listen_events: Vec, -} - -impl Desc { - pub fn from_cstr(s: *const c_char) -> ResultType { - let s = unsafe { CStr::from_ptr(s) }; - Ok(serde_json::from_str(s.to_str()?)?) - } - - pub fn meta(&self) -> &Meta { - &self.meta - } - - pub fn location(&self) -> &Location { - &self.location - } - - pub fn config(&self) -> &Config { - &self.config - } - - pub fn listen_events(&self) -> &Vec { - &self.listen_events - } -} diff --git a/src/plugin/errno.rs b/src/plugin/errno.rs deleted file mode 100644 index 6b1e3612d..000000000 --- a/src/plugin/errno.rs +++ /dev/null @@ -1,50 +0,0 @@ -#![allow(dead_code)] - -pub const ERR_SUCCESS: i32 = 0; - -// ====================================================== -// Errors from the plugins, must be handled by RustDesk - -pub const ERR_RUSTDESK_HANDLE_BASE: i32 = 10000; - -// not loaded -pub const ERR_PLUGIN_LOAD: i32 = 10001; -// not initialized -pub const ERR_PLUGIN_MSG_INIT: i32 = 10101; -pub const ERR_PLUGIN_MSG_INIT_INVALID: i32 = 10102; -pub const ERR_PLUGIN_MSG_GET_LOCAL_PEER_ID: i32 = 10103; -pub const ERR_PLUGIN_SIGNATURE_NOT_VERIFIED: i32 = 10104; -pub const ERR_PLUGIN_SIGNATURE_VERIFICATION_FAILED: i32 = 10105; -// invalid -pub const ERR_CALL_UNIMPLEMENTED: i32 = 10201; -pub const ERR_CALL_INVALID_METHOD: i32 = 10202; -pub const ERR_CALL_NOT_SUPPORTED_METHOD: i32 = 10203; -pub const ERR_CALL_INVALID_PEER: i32 = 10204; -// failed on calling -pub const ERR_CALL_INVALID_ARGS: i32 = 10301; -pub const ERR_PEER_ID_MISMATCH: i32 = 10302; -pub const ERR_CALL_CONFIG_VALUE: i32 = 10303; -// no handlers on calling -pub const ERR_NOT_HANDLED: i32 = 10401; - -// ====================================================== -// Errors from RustDesk callbacks. - -pub const ERR_CALLBACK_HANDLE_BASE: i32 = 20000; -pub const ERR_CALLBACK_PLUGIN_ID: i32 = 20001; -pub const ERR_CALLBACK_INVALID_ARGS: i32 = 20002; -pub const ERR_CALLBACK_INVALID_MSG: i32 = 20003; -pub const ERR_CALLBACK_TARGET: i32 = 20004; -pub const ERR_CALLBACK_TARGET_TYPE: i32 = 20005; -pub const ERR_CALLBACK_PEER_NOT_FOUND: i32 = 20006; - -pub const ERR_CALLBACK_FAILED: i32 = 21001; - -// ====================================================== -// Errors from the plugins, should be handled by the plugins. - -pub const ERR_PLUGIN_HANDLE_BASE: i32 = 30000; - -pub const EER_CALL_FAILED: i32 = 30021; -pub const ERR_PEER_ON_FAILED: i32 = 40012; -pub const ERR_PEER_OFF_FAILED: i32 = 40012; diff --git a/src/plugin/ipc.rs b/src/plugin/ipc.rs deleted file mode 100644 index 6a14ab00a..000000000 --- a/src/plugin/ipc.rs +++ /dev/null @@ -1,230 +0,0 @@ -// to-do: Interdependence(This mod and crate::ipc) is not good practice here. -use crate::ipc::{connect, Connection, Data}; -use hbb_common::{allow_err, log, tokio, ResultType}; -use serde_derive::{Deserialize, Serialize}; - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub enum InstallStatus { - Downloading(u8), - Installing, - Finished, - FailedCreating, - FailedDownloading, - FailedInstalling, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(tag = "t", content = "c")] -pub enum Plugin { - Config(String, String, Option), - ManagerConfig(String, Option), - ManagerPluginConfig(String, String, Option), - Load(String), - Reload(String), - InstallStatus((String, InstallStatus)), - Uninstall(String), -} - -#[tokio::main(flavor = "current_thread")] -pub async fn get_config(id: &str, name: &str) -> ResultType> { - get_config_async(id, name, 1_000).await -} - -#[tokio::main(flavor = "current_thread")] -pub async fn set_config(id: &str, name: &str, value: String) -> ResultType<()> { - set_config_async(id, name, value).await -} - -#[tokio::main(flavor = "current_thread")] -pub async fn get_manager_config(name: &str) -> ResultType> { - get_manager_config_async(name, 1_000).await -} - -#[tokio::main(flavor = "current_thread")] -pub async fn set_manager_config(name: &str, value: String) -> ResultType<()> { - set_manager_config_async(name, value).await -} - -#[tokio::main(flavor = "current_thread")] -pub async fn get_manager_plugin_config(id: &str, name: &str) -> ResultType> { - get_manager_plugin_config_async(id, name, 1_000).await -} - -#[tokio::main(flavor = "current_thread")] -pub async fn set_manager_plugin_config(id: &str, name: &str, value: String) -> ResultType<()> { - set_manager_plugin_config_async(id, name, value).await -} - -#[tokio::main(flavor = "current_thread")] -pub async fn load_plugin(id: &str) -> ResultType<()> { - load_plugin_async(id).await -} - -#[tokio::main(flavor = "current_thread")] -pub async fn reload_plugin(id: &str) -> ResultType<()> { - reload_plugin_async(id).await -} - -#[tokio::main(flavor = "current_thread")] -pub async fn uninstall_plugin(id: &str) -> ResultType<()> { - uninstall_plugin_async(id).await -} - -async fn get_config_async(id: &str, name: &str, ms_timeout: u64) -> ResultType> { - let mut c = connect(ms_timeout, "").await?; - c.send(&Data::Plugin(Plugin::Config( - id.to_owned(), - name.to_owned(), - None, - ))) - .await?; - if let Some(Data::Plugin(Plugin::Config(id2, name2, value))) = - c.next_timeout(ms_timeout).await? - { - if id == id2 && name == name2 { - return Ok(value); - } - } - return Ok(None); -} - -async fn set_config_async(id: &str, name: &str, value: String) -> ResultType<()> { - let mut c = connect(1000, "").await?; - c.send(&Data::Plugin(Plugin::Config( - id.to_owned(), - name.to_owned(), - Some(value), - ))) - .await?; - Ok(()) -} - -async fn get_manager_config_async(name: &str, ms_timeout: u64) -> ResultType> { - let mut c = connect(ms_timeout, "").await?; - c.send(&Data::Plugin(Plugin::ManagerConfig(name.to_owned(), None))) - .await?; - if let Some(Data::Plugin(Plugin::ManagerConfig(name2, value))) = - c.next_timeout(ms_timeout).await? - { - if name == name2 { - return Ok(value); - } - } - return Ok(None); -} - -async fn set_manager_config_async(name: &str, value: String) -> ResultType<()> { - let mut c = connect(1000, "").await?; - c.send(&Data::Plugin(Plugin::ManagerConfig( - name.to_owned(), - Some(value), - ))) - .await?; - Ok(()) -} - -async fn get_manager_plugin_config_async( - id: &str, - name: &str, - ms_timeout: u64, -) -> ResultType> { - let mut c = connect(ms_timeout, "").await?; - c.send(&Data::Plugin(Plugin::ManagerPluginConfig( - id.to_owned(), - name.to_owned(), - None, - ))) - .await?; - if let Some(Data::Plugin(Plugin::ManagerPluginConfig(id2, name2, value))) = - c.next_timeout(ms_timeout).await? - { - if id == id2 && name == name2 { - return Ok(value); - } - } - return Ok(None); -} - -async fn set_manager_plugin_config_async(id: &str, name: &str, value: String) -> ResultType<()> { - let mut c = connect(1000, "").await?; - c.send(&Data::Plugin(Plugin::ManagerPluginConfig( - id.to_owned(), - name.to_owned(), - Some(value), - ))) - .await?; - Ok(()) -} - -pub async fn load_plugin_async(id: &str) -> ResultType<()> { - let mut c = connect(1000, "").await?; - c.send(&Data::Plugin(Plugin::Load(id.to_owned()))).await?; - Ok(()) -} - -async fn reload_plugin_async(id: &str) -> ResultType<()> { - let mut c = connect(1000, "").await?; - c.send(&Data::Plugin(Plugin::Reload(id.to_owned()))).await?; - Ok(()) -} - -async fn uninstall_plugin_async(id: &str) -> ResultType<()> { - let mut c = connect(1000, "").await?; - c.send(&Data::Plugin(Plugin::Uninstall(id.to_owned()))) - .await?; - Ok(()) -} - -pub async fn handle_plugin(plugin: Plugin, stream: &mut Connection) { - match plugin { - Plugin::Config(id, name, value) => match value { - None => { - let value = super::SharedConfig::get(&id, &name); - allow_err!( - stream - .send(&Data::Plugin(Plugin::Config(id, name, value))) - .await - ); - } - Some(value) => { - allow_err!(super::SharedConfig::set(&id, &name, &value)); - } - }, - Plugin::ManagerConfig(name, value) => match value { - None => { - let value = super::ManagerConfig::get_option(&name); - allow_err!( - stream - .send(&Data::Plugin(Plugin::ManagerConfig(name, value))) - .await - ); - } - Some(value) => { - super::ManagerConfig::set_option(&name, &value); - } - }, - Plugin::ManagerPluginConfig(id, name, value) => match value { - None => { - let value = super::ManagerConfig::get_plugin_option(&id, &name); - allow_err!( - stream - .send(&Data::Plugin(Plugin::ManagerPluginConfig(id, name, value))) - .await - ); - } - Some(value) => { - super::ManagerConfig::set_plugin_option(&id, &name, &value); - } - }, - Plugin::Load(id) => { - allow_err!(super::load_plugin(&id)); - } - Plugin::Reload(id) => { - allow_err!(super::reload_plugin(&id)); - } - Plugin::Uninstall(id) => { - super::manager::uninstall_plugin(&id, false); - } - _ => {} - } -} diff --git a/src/plugin/manager.rs b/src/plugin/manager.rs deleted file mode 100644 index f59e4c9ff..000000000 --- a/src/plugin/manager.rs +++ /dev/null @@ -1,600 +0,0 @@ -// 1. Check update. -// 2. Install or uninstall. - -use super::{desc::Meta as PluginMeta, ipc::InstallStatus, *}; -use crate::flutter; -use crate::hbbs_http::create_http_client; -use hbb_common::{allow_err, bail, log, tokio, toml}; -use serde_derive::{Deserialize, Serialize}; -use serde_json; -use std::{ - collections::{HashMap, HashSet}, - fs::{read_to_string, remove_dir_all, OpenOptions}, - io::Write, - sync::{Arc, Mutex}, -}; - -const MSG_TO_UI_PLUGIN_MANAGER_LIST: &str = "plugin_list"; -const MSG_TO_UI_PLUGIN_MANAGER_INSTALL: &str = "plugin_install"; -const MSG_TO_UI_PLUGIN_MANAGER_UNINSTALL: &str = "plugin_uninstall"; - -const IPC_PLUGIN_POSTFIX: &str = "_plugin"; - -#[cfg(target_os = "windows")] -const PLUGIN_PLATFORM: &str = "windows"; -#[cfg(target_os = "linux")] -const PLUGIN_PLATFORM: &str = "linux"; -#[cfg(target_os = "macos")] -const PLUGIN_PLATFORM: &str = "macos"; - -lazy_static::lazy_static! { - static ref PLUGIN_INFO: Arc>> = Arc::new(Mutex::new(HashMap::new())); -} - -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct ManagerMeta { - pub version: String, - pub description: String, - pub plugins: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginSource { - pub name: String, - pub url: String, - pub description: String, -} - -#[derive(Debug, Serialize)] -pub struct PluginInfo { - pub source: PluginSource, - pub meta: PluginMeta, - pub installed_version: String, - pub invalid_reason: String, -} - -static PLUGIN_SOURCE_LOCAL: &str = "local"; - -fn get_plugin_source_list() -> Vec { - // Only one source for now. - // vec![PluginSource { - // name: "rustdesk".to_string(), - // url: "https://raw.githubusercontent.com/fufesou/rustdesk-plugins/main".to_string(), - // description: "".to_string(), - // }] - vec![] -} - -fn get_source_plugins() -> HashMap { - let mut plugins = HashMap::new(); - for source in get_plugin_source_list().into_iter() { - let url = format!("{}/meta.toml", source.url); - match create_http_client().get(&url).send() { - Ok(resp) => { - if !resp.status().is_success() { - log::error!( - "Failed to get plugin list from '{}', status code: {}", - url, - resp.status() - ); - } - if let Ok(text) = resp.text() { - match toml::from_str::(&text) { - Ok(manager_meta) => { - for meta in manager_meta.plugins.iter() { - if !meta - .platforms - .to_uppercase() - .contains(&PLUGIN_PLATFORM.to_uppercase()) - { - continue; - } - plugins.insert( - meta.id.clone(), - PluginInfo { - source: source.clone(), - meta: meta.clone(), - installed_version: "".to_string(), - invalid_reason: "".to_string(), - }, - ); - } - } - Err(e) => log::error!("Failed to parse plugin list from '{}', {}", url, e), - } - } - } - Err(e) => log::error!("Failed to get plugin list from '{}', {}", url, e), - } - } - plugins -} - -fn send_plugin_list_event(plugins: &HashMap) { - let mut plugin_list = plugins.values().collect::>(); - plugin_list.sort_by(|a, b| a.meta.name.cmp(&b.meta.name)); - if let Ok(plugin_list) = serde_json::to_string(&plugin_list) { - let mut m = HashMap::new(); - m.insert("name", MSG_TO_UI_TYPE_PLUGIN_MANAGER); - m.insert(MSG_TO_UI_PLUGIN_MANAGER_LIST, &plugin_list); - if let Ok(event) = serde_json::to_string(&m) { - let _res = flutter::push_global_event(flutter::APP_TYPE_MAIN, event.clone()); - } - } -} - -pub fn load_plugin_list() { - let mut plugin_info_lock = PLUGIN_INFO.lock().unwrap(); - let mut plugins = get_source_plugins(); - - // A big read lock is needed to prevent race conditions. - // Loading plugin list may be slow. - // Users may call uninstall plugin in the middle. - let plugin_infos = super::plugins::get_plugin_infos(); - let plugin_infos_read_lock = plugin_infos.read().unwrap(); - for (id, info) in plugin_infos_read_lock.iter() { - if info.uninstalled { - continue; - } - - if let Some(p) = plugins.get_mut(id) { - p.installed_version = info.desc.meta().version.clone(); - p.invalid_reason = "".to_string(); - } else { - plugins.insert( - id.to_string(), - PluginInfo { - source: PluginSource { - name: PLUGIN_SOURCE_LOCAL.to_string(), - url: PLUGIN_SOURCE_LOCAL_DIR.to_string(), - description: "".to_string(), - }, - meta: info.desc.meta().clone(), - installed_version: info.desc.meta().version.clone(), - invalid_reason: "".to_string(), - }, - ); - } - } - send_plugin_list_event(&plugins); - *plugin_info_lock = plugins; -} - -#[cfg(target_os = "windows")] -fn elevate_install( - plugin_id: &str, - plugin_url: &str, - same_plugin_exists: bool, -) -> ResultType { - // to-do: Support args with space in quotes. 'arg 1' and "arg 2" - let args = if same_plugin_exists { - format!("--plugin-install {}", plugin_id) - } else { - format!("--plugin-install {} {}", plugin_id, plugin_url) - }; - crate::platform::elevate(&args) -} - -#[cfg(target_os = "linux")] -fn elevate_install( - plugin_id: &str, - plugin_url: &str, - same_plugin_exists: bool, -) -> ResultType { - let mut args = vec!["--plugin-install", plugin_id]; - if !same_plugin_exists { - args.push(&plugin_url); - } - crate::platform::elevate(args) -} - -#[cfg(target_os = "macos")] -fn elevate_install( - plugin_id: &str, - plugin_url: &str, - same_plugin_exists: bool, -) -> ResultType { - let mut args = vec!["--plugin-install", plugin_id]; - if !same_plugin_exists { - args.push(&plugin_url); - } - crate::platform::elevate(args, "RustDesk wants to install then plugin") -} - -#[inline] -#[cfg(target_os = "windows")] -fn elevate_uninstall(plugin_id: &str) -> ResultType { - crate::platform::elevate(&format!("--plugin-uninstall {}", plugin_id)) -} - -#[inline] -#[cfg(target_os = "linux")] -fn elevate_uninstall(plugin_id: &str) -> ResultType { - crate::platform::elevate(vec!["--plugin-uninstall", plugin_id]) -} - -#[inline] -#[cfg(target_os = "macos")] -fn elevate_uninstall(plugin_id: &str) -> ResultType { - crate::platform::elevate( - vec!["--plugin-uninstall", plugin_id], - "RustDesk wants to uninstall the plugin", - ) -} - -pub fn install_plugin(id: &str) -> ResultType<()> { - match PLUGIN_INFO.lock().unwrap().get(id) { - Some(plugin) => { - let mut same_plugin_exists = false; - if let Some(version) = super::plugins::get_version(id) { - if version == plugin.meta.version { - same_plugin_exists = true; - } - } - let plugin_url = format!( - "{}/plugins/{}/{}/{}_{}.zip", - plugin.source.url, - plugin.meta.id, - PLUGIN_PLATFORM, - plugin.meta.id, - plugin.meta.version - ); - let allowed_install = elevate_install(id, &plugin_url, same_plugin_exists)?; - if allowed_install && same_plugin_exists { - super::ipc::load_plugin(id)?; - super::plugins::load_plugin(id)?; - super::plugins::mark_uninstalled(id, false); - push_install_event(id, "finished"); - } - Ok(()) - } - None => { - bail!("Plugin not found: {}", id); - } - } -} - -fn get_uninstalled_plugins(uninstalled_plugin_set: &HashSet) -> ResultType> { - let plugins_dir = super::get_plugins_dir()?; - let mut plugins = Vec::new(); - if plugins_dir.exists() { - for entry in std::fs::read_dir(plugins_dir)? { - match entry { - Ok(entry) => { - let plugin_dir = entry.path(); - if plugin_dir.is_dir() { - if let Some(id) = plugin_dir.file_name().and_then(|n| n.to_str()) { - if uninstalled_plugin_set.contains(id) { - plugins.push(id.to_string()); - } - } - } - } - Err(e) => { - log::error!("Failed to read plugins dir entry, {}", e); - } - } - } - } - Ok(plugins) -} - -pub fn remove_uninstalled() -> ResultType<()> { - let mut uninstalled_plugin_set = get_uninstall_id_set()?; - for id in get_uninstalled_plugins(&uninstalled_plugin_set)?.iter() { - super::config::remove(id as _); - if let Ok(dir) = super::get_plugin_dir(id as _) { - allow_err!(remove_dir_all(dir.clone())); - if !dir.exists() { - uninstalled_plugin_set.remove(id); - } - } - } - allow_err!(update_uninstall_id_set(uninstalled_plugin_set)); - Ok(()) -} - -pub fn uninstall_plugin(id: &str, called_by_ui: bool) { - if called_by_ui { - match elevate_uninstall(id) { - Ok(true) => { - if let Err(e) = super::ipc::uninstall_plugin(id) { - log::error!("Failed to uninstall plugin '{}': {}", id, e); - push_uninstall_event(id, "failed"); - return; - } - super::plugins::unload_plugin(id); - super::plugins::mark_uninstalled(id, true); - super::config::remove(id); - push_uninstall_event(id, ""); - } - Ok(false) => { - return; - } - Err(e) => { - log::error!( - "Failed to uninstall plugin '{}', check permission error: {}", - id, - e - ); - push_uninstall_event(id, "failed"); - return; - } - } - } - - if super::is_server_running() { - super::plugins::unload_plugin(&id); - } -} - -fn push_event(id: &str, r#type: &str, msg: &str) { - let mut m = HashMap::new(); - m.insert("name", MSG_TO_UI_TYPE_PLUGIN_MANAGER); - m.insert("id", id); - m.insert(r#type, msg); - if let Ok(event) = serde_json::to_string(&m) { - let _res = flutter::push_global_event(flutter::APP_TYPE_MAIN, event.clone()); - } -} - -#[inline] -fn push_uninstall_event(id: &str, msg: &str) { - push_event(id, MSG_TO_UI_PLUGIN_MANAGER_UNINSTALL, msg); -} - -#[inline] -fn push_install_event(id: &str, msg: &str) { - push_event(id, MSG_TO_UI_PLUGIN_MANAGER_INSTALL, msg); -} - -async fn handle_conn(mut stream: crate::ipc::Connection) { - loop { - tokio::select! { - res = stream.next() => { - match res { - Err(err) => { - log::trace!("plugin ipc connection closed: {}", err); - break; - } - Ok(Some(data)) => { - match &data { - crate::ipc::Data::Plugin(super::ipc::Plugin::InstallStatus((id, status))) => { - match status { - InstallStatus::Downloading(n) => { - push_install_event(&id, &format!("downloading-{}", n)); - }, - InstallStatus::Installing => { - push_install_event(&id, "installing"); - } - InstallStatus::Finished => { - allow_err!(super::plugins::load_plugin(&id)); - allow_err!(super::ipc::load_plugin_async(id).await); - std::thread::spawn(load_plugin_list); - push_install_event(&id, "finished"); - } - InstallStatus::FailedCreating => { - push_install_event(&id, "failed-creating"); - } - InstallStatus::FailedDownloading => { - push_install_event(&id, "failed-downloading"); - } - InstallStatus::FailedInstalling => { - push_install_event(&id, "failed-installing"); - } - } - } - _ => {} - } - } - _ => { - } - } - } - } - } -} - -#[cfg(not(any(target_os = "android", target_os = "ios")))] -#[tokio::main] -pub async fn start_ipc() { - match crate::ipc::new_listener(IPC_PLUGIN_POSTFIX).await { - Ok(mut incoming) => { - while let Some(result) = incoming.next().await { - match result { - Ok(stream) => { - log::debug!("Got new connection"); - tokio::spawn(handle_conn(crate::ipc::Connection::new(stream))); - } - Err(err) => { - log::error!("Couldn't get plugin client: {:?}", err); - } - } - } - } - Err(err) => { - log::error!("Failed to start plugin ipc server: {}", err); - } - } -} - -pub(super) fn get_uninstall_id_set() -> ResultType> { - let uninstall_file_path = super::get_uninstall_file_path()?; - if !uninstall_file_path.exists() { - std::fs::create_dir_all(&super::get_plugins_dir()?)?; - return Ok(HashSet::new()); - } - let s = read_to_string(uninstall_file_path)?; - Ok(serde_json::from_str::>(&s)?) -} - -fn update_uninstall_id_set(set: HashSet) -> ResultType<()> { - let content = serde_json::to_string(&set)?; - let file = OpenOptions::new() - .write(true) - .truncate(true) - .create(true) - .open(super::get_uninstall_file_path()?)?; - let mut writer = std::io::BufWriter::new(file); - writer.write_all(content.as_bytes())?; - Ok(()) -} - -// install process -pub(super) mod install { - use super::IPC_PLUGIN_POSTFIX; - use crate::hbbs_http::create_http_client; - use crate::{ - ipc::{connect, Data}, - plugin::ipc::{InstallStatus, Plugin}, - }; - use hbb_common::{allow_err, bail, log, tokio, ResultType}; - use std::{ - fs::File, - io::{BufReader, BufWriter, Write}, - path::Path, - }; - use zip::ZipArchive; - - #[tokio::main(flavor = "current_thread")] - async fn send_install_status(id: &str, status: InstallStatus) { - allow_err!(_send_install_status(id, status).await); - } - - async fn _send_install_status(id: &str, status: InstallStatus) -> ResultType<()> { - let mut c = connect(1_000, IPC_PLUGIN_POSTFIX).await?; - c.send(&Data::Plugin(Plugin::InstallStatus(( - id.to_string(), - status, - )))) - .await?; - Ok(()) - } - - fn download_to_file(url: &str, file: File) -> ResultType<()> { - let resp = match create_http_client().get(url).send() { - Ok(resp) => resp, - Err(e) => { - bail!("get plugin from '{}', {}", url, e); - } - }; - - if !resp.status().is_success() { - bail!("get plugin from '{}', status code: {}", url, resp.status()); - } - - let mut writer = BufWriter::new(file); - writer.write_all(resp.bytes()?.as_ref())?; - Ok(()) - } - - fn download_file(id: &str, url: &str, filename: &Path) -> bool { - let file = match File::create(filename) { - Ok(f) => f, - Err(e) => { - log::error!("Failed to create plugin file: {}", e); - send_install_status(id, InstallStatus::FailedCreating); - return false; - } - }; - if let Err(e) = download_to_file(url, file) { - log::error!("Failed to download plugin '{}', {}", id, e); - send_install_status(id, InstallStatus::FailedDownloading); - return false; - } - true - } - - fn do_install_file(filename: &Path, target_dir: &Path) -> ResultType<()> { - let mut zip = ZipArchive::new(BufReader::new(File::open(filename)?))?; - for i in 0..zip.len() { - let mut file = zip.by_index(i)?; - let file_path = target_dir.join(file.name()); - if file.name().ends_with("/") { - std::fs::create_dir_all(&file_path)?; - } else { - if let Some(p) = file_path.parent() { - if !p.exists() { - std::fs::create_dir_all(&p)?; - } - } - let mut outfile = File::create(&file_path)?; - std::io::copy(&mut file, &mut outfile)?; - } - } - Ok(()) - } - - pub fn change_uninstall_plugin(id: &str, add: bool) { - match super::get_uninstall_id_set() { - Ok(mut set) => { - if add { - set.insert(id.to_string()); - } else { - set.remove(id); - } - if let Err(e) = super::update_uninstall_id_set(set) { - log::error!("Failed to write uninstall list, {}", e); - } - } - Err(e) => log::error!( - "Failed to get plugins dir, unable to read uninstall list, {}", - e - ), - } - } - - pub fn install_plugin_with_url(id: &str, url: &str) { - log::info!("Installing plugin '{}', url: {}", id, url); - let plugin_dir = match super::super::get_plugin_dir(id) { - Ok(d) => d, - Err(e) => { - send_install_status(id, InstallStatus::FailedCreating); - log::error!("Failed to get plugin dir: {}", e); - return; - } - }; - if !plugin_dir.exists() { - if let Err(e) = std::fs::create_dir_all(&plugin_dir) { - send_install_status(id, InstallStatus::FailedCreating); - log::error!("Failed to create plugin dir: {}", e); - return; - } - } - - let filename = match url.rsplit('/').next() { - Some(filename) => plugin_dir.join(filename), - None => { - send_install_status(id, InstallStatus::FailedDownloading); - log::error!("Failed to download plugin file, invalid url: {}", url); - return; - } - }; - - let filename_to_remove = filename.clone(); - let _call_on_ret = crate::common::SimpleCallOnReturn { - b: true, - f: Box::new(move || { - if let Err(e) = std::fs::remove_file(&filename_to_remove) { - log::error!("Failed to remove plugin file: {}", e); - } - }), - }; - - // download - if !download_file(id, url, &filename) { - return; - } - - // install - send_install_status(id, InstallStatus::Installing); - if let Err(e) = do_install_file(&filename, &plugin_dir) { - log::error!("Failed to install plugin: {}", e); - send_install_status(id, InstallStatus::FailedInstalling); - return; - } - - // finished - send_install_status(id, InstallStatus::Finished); - } -} diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs deleted file mode 100644 index bd4b21b67..000000000 --- a/src/plugin/mod.rs +++ /dev/null @@ -1,188 +0,0 @@ -use hbb_common::{bail, libc, log, ResultType}; -#[cfg(target_os = "windows")] -use std::env; -use std::{ - ffi::{c_char, c_int, c_void, CStr}, - path::PathBuf, - ptr::null, -}; - -mod callback_ext; -mod callback_msg; -mod config; -pub mod desc; -mod errno; -pub mod ipc; -mod manager; -pub mod native; -pub mod native_handlers; -mod plog; -mod plugins; - -pub use manager::{ - install::{change_uninstall_plugin, install_plugin_with_url}, - install_plugin, load_plugin_list, remove_uninstalled, uninstall_plugin, -}; -pub use plugins::{ - handle_client_event, handle_listen_event, handle_server_event, handle_ui_event, load_plugin, - reload_plugin, sync_ui, unload_plugin, -}; - -const MSG_TO_UI_TYPE_PLUGIN_EVENT: &str = "plugin_event"; -const MSG_TO_UI_TYPE_PLUGIN_RELOAD: &str = "plugin_reload"; -const MSG_TO_UI_TYPE_PLUGIN_OPTION: &str = "plugin_option"; -const MSG_TO_UI_TYPE_PLUGIN_MANAGER: &str = "plugin_manager"; - -pub const EVENT_ON_CONN_CLIENT: &str = "on_conn_client"; -pub const EVENT_ON_CONN_SERVER: &str = "on_conn_server"; -pub const EVENT_ON_CONN_CLOSE_CLIENT: &str = "on_conn_close_client"; -pub const EVENT_ON_CONN_CLOSE_SERVER: &str = "on_conn_close_server"; - -static PLUGIN_SOURCE_LOCAL_DIR: &str = "plugins"; - -pub use config::{ManagerConfig, PeerConfig, SharedConfig}; - -/// Common plugin return. -/// -/// [Note] -/// The msg must be nullptr if code is errno::ERR_SUCCESS. -/// The msg must be freed by caller if code is not errno::ERR_SUCCESS. -#[repr(C)] -#[derive(Debug)] -pub struct PluginReturn { - pub code: c_int, - pub msg: *const c_char, -} - -impl PluginReturn { - pub fn success() -> Self { - Self { - code: errno::ERR_SUCCESS, - msg: null(), - } - } - - #[inline] - pub fn is_success(&self) -> bool { - self.code == errno::ERR_SUCCESS - } - - pub fn new(code: c_int, msg: &str) -> Self { - Self { - code, - msg: str_to_cstr_ret(msg), - } - } - - pub fn get_code_msg(&mut self, id: &str) -> (i32, String) { - if self.is_success() { - (self.code, "".to_owned()) - } else { - if self.msg.is_null() { - log::warn!( - "The message pointer from the plugin '{}' is null, but the error code is {}", - id, - self.code - ); - return (self.code, "".to_owned()); - } - let msg = cstr_to_string(self.msg).unwrap_or_default(); - free_c_ptr(self.msg as _); - self.msg = null(); - (self.code as _, msg) - } - } -} - -fn is_server_running() -> bool { - crate::common::is_server() || crate::common::is_server_running() -} - -pub fn init() { - if !is_server_running() { - std::thread::spawn(move || manager::start_ipc()); - } else { - if let Err(e) = remove_uninstalled() { - log::error!("Failed to remove plugins: {}", e); - } - } - match manager::get_uninstall_id_set() { - Ok(ids) => { - if let Err(e) = plugins::load_plugins(&ids) { - log::error!("Failed to load plugins: {}", e); - } - } - Err(e) => { - log::error!("Failed to load plugins: {}", e); - } - } -} - -#[inline] -#[cfg(target_os = "windows")] -fn get_share_dir() -> ResultType { - Ok(PathBuf::from(env::var("ProgramData")?)) -} - -#[inline] -#[cfg(target_os = "linux")] -fn get_share_dir() -> ResultType { - Ok(PathBuf::from("/usr/share")) -} - -#[inline] -#[cfg(target_os = "macos")] -fn get_share_dir() -> ResultType { - Ok(PathBuf::from("/Library/Application Support")) -} - -#[inline] -fn get_plugins_dir() -> ResultType { - Ok(get_share_dir()? - .join("RustDesk") - .join(PLUGIN_SOURCE_LOCAL_DIR)) -} - -#[inline] -fn get_plugin_dir(id: &str) -> ResultType { - Ok(get_plugins_dir()?.join(id)) -} - -#[inline] -fn get_uninstall_file_path() -> ResultType { - Ok(get_plugins_dir()?.join("uninstall_list")) -} - -#[inline] -fn cstr_to_string(cstr: *const c_char) -> ResultType { - if cstr.is_null() { - bail!("failed to convert string, the pointer is null"); - } - Ok(String::from_utf8(unsafe { - CStr::from_ptr(cstr).to_bytes().to_vec() - })?) -} - -#[inline] -fn str_to_cstr_ret(s: &str) -> *const c_char { - let mut s = s.as_bytes().to_vec(); - s.push(0); - unsafe { - let r = libc::malloc(s.len()) as *mut c_char; - libc::memcpy( - r as *mut libc::c_void, - s.as_ptr() as *const libc::c_void, - s.len(), - ); - r - } -} - -#[inline] -fn free_c_ptr(p: *mut c_void) { - if !p.is_null() { - unsafe { - libc::free(p); - } - } -} diff --git a/src/plugin/native.rs b/src/plugin/native.rs deleted file mode 100644 index ce885c77c..000000000 --- a/src/plugin/native.rs +++ /dev/null @@ -1,40 +0,0 @@ -use std::{ - ffi::{c_char, c_int, c_void}, - os::raw::c_uint, -}; - -use hbb_common::log::error; - -use super::{ - cstr_to_string, - errno::ERR_NOT_HANDLED, - native_handlers::{Callable, NATIVE_HANDLERS_REGISTRAR}, -}; -/// The native returned value from librustdesk native. -/// -/// [Note] -/// The data is owned by librustdesk. -#[repr(C)] -pub struct NativeReturnValue { - pub return_type: c_int, - pub data: *const c_void, -} - -pub(super) extern "C" fn cb_native_data( - method: *const c_char, - json: *const c_char, - raw: *const c_void, - raw_len: usize, -) -> NativeReturnValue { - let ret = match cstr_to_string(method) { - Ok(method) => NATIVE_HANDLERS_REGISTRAR.call(&method, json, raw, raw_len), - Err(err) => { - error!("cb_native_data error: {}", err); - None - } - }; - return ret.unwrap_or(NativeReturnValue { - return_type: ERR_NOT_HANDLED, - data: std::ptr::null(), - }); -} diff --git a/src/plugin/native_handlers/macros.rs b/src/plugin/native_handlers/macros.rs deleted file mode 100644 index 82d7e10a6..000000000 --- a/src/plugin/native_handlers/macros.rs +++ /dev/null @@ -1,27 +0,0 @@ -#[macro_export] -macro_rules! return_if_not_method { - ($call: ident, $prefix: ident) => { - if $call.starts_with($prefix) { - return None; - } - }; -} - -#[macro_export] -macro_rules! call_if_method { - ($call: ident ,$method: literal, $block: block) => { - if ($call != $method) { - $block - } - }; -} - -#[macro_export] -macro_rules! define_method_prefix { - ($prefix: literal) => { - #[inline] - fn method_prefix(&self) -> &'static str { - $prefix - } - }; -} diff --git a/src/plugin/native_handlers/mod.rs b/src/plugin/native_handlers/mod.rs deleted file mode 100644 index 7d590ab1e..000000000 --- a/src/plugin/native_handlers/mod.rs +++ /dev/null @@ -1,126 +0,0 @@ -use std::{ - ffi::c_void, - sync::{Arc, RwLock}, - vec, -}; - -use hbb_common::libc::c_char; -use lazy_static::lazy_static; -use serde_json::Map; - -use crate::return_if_not_method; - -use self::{session::PluginNativeSessionHandler, ui::PluginNativeUIHandler}; - -use super::cstr_to_string; - -mod macros; -pub mod session; -pub mod ui; - -pub type NR = super::native::NativeReturnValue; -pub type PluginNativeHandlerRegistrar = NativeHandlerRegistrar>; - -lazy_static! { - pub static ref NATIVE_HANDLERS_REGISTRAR: Arc = - Arc::new(PluginNativeHandlerRegistrar::default()); -} - -#[derive(Clone)] -pub struct NativeHandlerRegistrar { - handlers: Arc>>, -} - -impl Default for PluginNativeHandlerRegistrar { - fn default() -> Self { - Self { - handlers: Arc::new(RwLock::new(vec![ - // Add prebuilt native handlers here. - Box::new(PluginNativeSessionHandler::default()), - Box::new(PluginNativeUIHandler::default()), - ])), - } - } -} - -pub(self) trait PluginNativeHandler { - /// The method prefix handled by this handler.s - fn method_prefix(&self) -> &'static str; - - /// Try to handle the method with the given data. - /// - /// Returns: None for the message does not be handled by this handler. - fn on_message(&self, method: &str, data: &Map) -> Option; - - /// Try to handle the method with the given data and extra void binary data. - /// - /// Returns: None for the message does not be handled by this handler. - fn on_message_raw( - &self, - method: &str, - data: &Map, - raw: *const c_void, - raw_len: usize, - ) -> Option; -} - -pub trait Callable { - fn call( - &self, - method: &String, - json: *const c_char, - raw: *const c_void, - raw_len: usize, - ) -> Option { - None - } -} - -impl Callable for T -where - T: PluginNativeHandler + Send + Sync, -{ - fn call( - &self, - method: &String, - json: *const c_char, - raw: *const c_void, - raw_len: usize, - ) -> Option { - let prefix = self.method_prefix(); - return_if_not_method!(method, prefix); - match cstr_to_string(json) { - Ok(s) => { - if let Ok(json) = serde_json::from_str(s.as_str()) { - let method_suffix = &method[prefix.len()..]; - if raw != std::ptr::null() && raw_len > 0 { - return self.on_message_raw(method_suffix, &json, raw, raw_len); - } else { - return self.on_message(method_suffix, &json); - } - } else { - return None; - } - } - Err(_) => return None, - } - } -} - -impl Callable for PluginNativeHandlerRegistrar { - fn call( - &self, - method: &String, - json: *const c_char, - raw: *const c_void, - raw_len: usize, - ) -> Option { - for handler in self.handlers.read().unwrap().iter() { - let ret = handler.call(method, json, raw, raw_len); - if ret.is_some() { - return ret; - } - } - None - } -} diff --git a/src/plugin/native_handlers/session.rs b/src/plugin/native_handlers/session.rs deleted file mode 100644 index 3a3f62f8d..000000000 --- a/src/plugin/native_handlers/session.rs +++ /dev/null @@ -1,219 +0,0 @@ -use std::{ - collections::HashMap, - ffi::{c_char, c_void}, - ptr::addr_of_mut, - sync::{Arc, RwLock}, -}; - -use flutter_rust_bridge::StreamSink; - -use crate::{define_method_prefix, flutter_ffi::EventToUI}; - -const MSG_TO_UI_TYPE_SESSION_CREATED: &str = "session_created"; - -use super::PluginNativeHandler; - -pub type OnSessionRgbaCallback = unsafe extern "C" fn( - *const c_char, // Session ID - *mut c_void, // raw data - *mut usize, // width - *mut usize, // height, - *mut usize, // stride, - *mut scrap::ImageFormat, // ImageFormat -); - -#[derive(Default)] -/// Session related handler for librustdesk core. -pub struct PluginNativeSessionHandler { - sessions: Arc>>, - cbs: Arc>>, -} - -lazy_static::lazy_static! { - pub static ref SESSION_HANDLER: Arc = Arc::new(PluginNativeSessionHandler::default()); -} - -impl PluginNativeHandler for PluginNativeSessionHandler { - define_method_prefix!("session_"); - - fn on_message( - &self, - method: &str, - data: &serde_json::Map, - ) -> Option { - match method { - "create_session" => { - if let Some(id) = data.get("id") { - if let Some(id) = id.as_str() { - return Some(super::NR { - return_type: 1, - data: SESSION_HANDLER.create_session(id.to_string()).as_ptr() as _, - }); - } - } - } - "start_session" => { - if let Some(id) = data.get("id") { - if let Some(id) = id.as_str() { - let sessions = SESSION_HANDLER.sessions.read().unwrap(); - for session in sessions.iter() { - if session.id == id { - let round = - session.connection_round_state.lock().unwrap().new_round(); - crate::ui_session_interface::io_loop(session.clone(), round); - } - } - } - } - } - "remove_session_hook" => { - if let Some(id) = data.get("id") { - if let Some(id) = id.as_str() { - SESSION_HANDLER.remove_session_hook(id.to_string()); - return Some(super::NR { - return_type: 0, - data: std::ptr::null(), - }); - } - } - } - "remove_session" => { - if let Some(id) = data.get("id") { - if let Some(id) = id.as_str() { - SESSION_HANDLER.remove_session(id.to_owned()); - return Some(super::NR { - return_type: 0, - data: std::ptr::null(), - }); - } - } - } - _ => {} - } - None - } - - fn on_message_raw( - &self, - method: &str, - data: &serde_json::Map, - raw: *const std::ffi::c_void, - _raw_len: usize, - ) -> Option { - match method { - "add_session_hook" => { - if let Some(id) = data.get("id") { - if let Some(id) = id.as_str() { - let cb: OnSessionRgbaCallback = unsafe { std::mem::transmute(raw) }; - SESSION_HANDLER.add_session_hook(id.to_string(), cb); - return Some(super::NR { - return_type: 0, - data: std::ptr::null(), - }); - } - } - } - _ => {} - } - None - } -} - -impl PluginNativeSessionHandler { - fn create_session(&self, session_id: String) -> String { - let session = - crate::flutter::session_add(&session_id, false, false, false, "", false, "".to_owned()); - if let Ok(session) = session { - let mut sessions = self.sessions.write().unwrap(); - sessions.push(session); - // push a event to notify flutter to bind a event stream for this session. - let mut m = HashMap::new(); - m.insert("name", MSG_TO_UI_TYPE_SESSION_CREATED); - m.insert("session_id", &session_id); - // todo: APP_TYPE_DESKTOP_REMOTE is not used anymore. - // crate::flutter::APP_TYPE_DESKTOP_REMOTE + window id, is used for multi-window support. - crate::flutter::push_global_event( - crate::flutter::APP_TYPE_DESKTOP_REMOTE, - serde_json::to_string(&m).unwrap_or("".to_string()), - ); - return session_id; - } else { - return "".to_string(); - } - } - - fn add_session_hook(&self, session_id: String, cb: OnSessionRgbaCallback) { - let sessions = self.sessions.read().unwrap(); - for session in sessions.iter() { - if session.id == session_id { - self.cbs.write().unwrap().insert(session_id.to_owned(), cb); - session.ui_handler.add_session_hook( - session_id, - crate::flutter::SessionHook::OnSessionRgba(session_rgba_cb), - ); - break; - } - } - } - - fn remove_session_hook(&self, session_id: String) { - let sessions = self.sessions.read().unwrap(); - for session in sessions.iter() { - if session.id == session_id { - session.ui_handler.remove_session_hook(&session_id); - } - } - } - - fn remove_session(&self, session_id: String) { - let _ = self.cbs.write().unwrap().remove(&session_id); - let mut sessions = self.sessions.write().unwrap(); - for i in 0..sessions.len() { - if sessions[i].id == session_id { - sessions[i].close_event_stream(); - sessions[i].close(); - sessions.remove(i); - } - } - } - - #[inline] - // The callback function for rgba data - fn session_rgba_cb(&self, session_id: String, rgb: &mut scrap::ImageRgb) { - let cbs = self.cbs.read().unwrap(); - if let Some(cb) = cbs.get(&session_id) { - unsafe { - cb( - session_id.as_ptr() as _, - rgb.raw.as_mut_ptr() as _, - addr_of_mut!(rgb.w), - addr_of_mut!(rgb.h), - addr_of_mut!(rgb.stride), - addr_of_mut!(rgb.fmt), - ); - } - } - } - - #[inline] - // The callback function for rgba data - fn session_register_event_stream(&self, session_id: String, stream: StreamSink) { - let sessions = self.sessions.read().unwrap(); - for session in sessions.iter() { - if session.id == session_id { - *session.event_stream.write().unwrap() = Some(stream); - break; - } - } - } -} - -#[inline] -fn session_rgba_cb(id: String, rgb: &mut scrap::ImageRgb) { - SESSION_HANDLER.session_rgba_cb(id, rgb); -} - -#[inline] -pub fn session_register_event_stream(id: String, stream: StreamSink) { - SESSION_HANDLER.session_register_event_stream(id, stream); -} diff --git a/src/plugin/native_handlers/ui.rs b/src/plugin/native_handlers/ui.rs deleted file mode 100644 index aec7facd8..000000000 --- a/src/plugin/native_handlers/ui.rs +++ /dev/null @@ -1,143 +0,0 @@ -use std::{collections::HashMap, ffi::c_void, os::raw::c_int}; - -use serde_json::json; - -use crate::{define_method_prefix, flutter::APP_TYPE_MAIN}; - -use super::PluginNativeHandler; - -#[derive(Default)] -pub struct PluginNativeUIHandler; - -/// Callback for UI interface. -/// -/// [Note] -/// We will transfer the native callback to u64 and post it to flutter. -/// The flutter thread will directly call this method. -/// -/// an example of `data` is: -/// ``` -/// { -/// "cb": 0x1234567890 -/// } -/// ``` -/// [Safety] -/// Please make sure the callback u provided is VALID, or memory or calling issues may occur to cause the program crash! -pub type OnUIReturnCallback = - extern "C" fn(return_code: c_int, data: *const c_void, data_len: u64, user_data: *const c_void); - -impl PluginNativeHandler for PluginNativeUIHandler { - define_method_prefix!("ui_"); - - fn on_message( - &self, - method: &str, - data: &serde_json::Map, - ) -> Option { - match method { - "select_peers_async" => { - if let Some(cb) = data.get("cb") { - if let Some(cb) = cb.as_u64() { - let user_data = match data.get("user_data") { - Some(user_data) => user_data.as_u64().unwrap_or(0), - None => 0, - }; - self.select_peers_async(cb, user_data); - return Some(super::NR { - return_type: 0, - data: std::ptr::null(), - }); - } - } - return Some(super::NR { - return_type: -1, - data: "missing cb field message".as_ptr() as _, - }); - } - "register_ui_entry" => { - let title; - if let Some(v) = data.get("title") { - title = v.as_str().unwrap_or(""); - } else { - title = ""; - } - if let Some(on_tap_cb) = data.get("on_tap_cb") { - if let Some(on_tap_cb) = on_tap_cb.as_u64() { - let user_data = match data.get("user_data") { - Some(user_data) => user_data.as_u64().unwrap_or(0), - None => 0, - }; - self.register_ui_entry(title, on_tap_cb, user_data); - return Some(super::NR { - return_type: 0, - data: std::ptr::null(), - }); - } - } - return Some(super::NR { - return_type: -1, - data: "missing cb field message".as_ptr() as _, - }); - } - _ => {} - } - None - } - - fn on_message_raw( - &self, - method: &str, - data: &serde_json::Map, - raw: *const std::ffi::c_void, - _raw_len: usize, - ) -> Option { - None - } -} - -impl PluginNativeUIHandler { - /// Call with method `select_peers_async` and the following json: - /// ```json - /// { - /// "cb": 0, // The function address - /// "user_data": 0 // An opaque pointer value passed to the callback. - /// } - /// ``` - /// - /// [Arguments] - /// @param cb: the function address with type [OnUIReturnCallback]. - /// @param user_data: the function will be called with this value. - fn select_peers_async(&self, cb: u64, user_data: u64) { - let mut param = HashMap::new(); - param.insert("name", json!("native_ui")); - param.insert("action", json!("select_peers")); - param.insert("cb", json!(cb)); - param.insert("user_data", json!(user_data)); - crate::flutter::push_global_event( - APP_TYPE_MAIN, - serde_json::to_string(¶m).unwrap_or("".to_string()), - ); - } - - /// Call with method `register_ui_entry` and the following json: - /// ``` - /// { - /// - /// "on_tap_cb": 0, // The function address - /// "user_data": 0, // An opaque pointer value passed to the callback. - /// "title": "entry name" - /// } - /// ``` - fn register_ui_entry(&self, title: &str, on_tap_cb: u64, user_data: u64) { - let mut param = HashMap::new(); - param.insert("name", json!("native_ui")); - param.insert("action", json!("register_ui_entry")); - param.insert("title", json!(title)); - param.insert("cb", json!(on_tap_cb)); - param.insert("user_data", json!(user_data)); - crate::flutter::push_global_event( - APP_TYPE_MAIN, - serde_json::to_string(¶m).unwrap_or("".to_string()), - ); - } -} diff --git a/src/plugin/plog.rs b/src/plugin/plog.rs deleted file mode 100644 index f1e78d36e..000000000 --- a/src/plugin/plog.rs +++ /dev/null @@ -1,34 +0,0 @@ -use hbb_common::log; -use std::ffi::c_char; - -const LOG_LEVEL_TRACE: &[u8; 6] = b"trace\0"; -const LOG_LEVEL_DEBUG: &[u8; 6] = b"debug\0"; -const LOG_LEVEL_INFO: &[u8; 5] = b"info\0"; -const LOG_LEVEL_WARN: &[u8; 5] = b"warn\0"; -const LOG_LEVEL_ERROR: &[u8; 6] = b"error\0"; - -#[inline] -fn is_level(level: *const c_char, level_bytes: &[u8]) -> bool { - level_bytes == unsafe { std::slice::from_raw_parts(level as *const u8, level_bytes.len()) } -} - -#[no_mangle] -pub(super) extern "C" fn plugin_log(level: *const c_char, msg: *const c_char) { - if level.is_null() || msg.is_null() { - return; - } - - if let Ok(msg) = super::cstr_to_string(msg) { - if is_level(level, LOG_LEVEL_TRACE) { - log::trace!("{}", msg); - } else if is_level(level, LOG_LEVEL_DEBUG) { - log::debug!("{}", msg); - } else if is_level(level, LOG_LEVEL_INFO) { - log::info!("{}", msg); - } else if is_level(level, LOG_LEVEL_WARN) { - log::warn!("{}", msg); - } else if is_level(level, LOG_LEVEL_ERROR) { - log::error!("{}", msg); - } - } -} diff --git a/src/plugin/plugins.rs b/src/plugin/plugins.rs deleted file mode 100644 index bf980ee8c..000000000 --- a/src/plugin/plugins.rs +++ /dev/null @@ -1,659 +0,0 @@ -use super::{desc::Desc, errno::*, *}; -#[cfg(not(debug_assertions))] -use crate::common::is_server; -use crate::flutter; -use hbb_common::{ - bail, - dlopen::symbor::Library, - lazy_static, log, - message_proto::{Message, Misc, PluginFailure, PluginRequest}, - ResultType, -}; -use serde_derive::Serialize; -use std::{ - collections::{HashMap, HashSet}, - ffi::{c_char, c_void}, - path::Path, - sync::{Arc, RwLock}, -}; - -pub const METHOD_HANDLE_STATUS: &[u8; 14] = b"handle_status\0"; -pub const METHOD_HANDLE_SIGNATURE_VERIFICATION: &[u8; 30] = b"handle_signature_verification\0"; -const METHOD_HANDLE_UI: &[u8; 10] = b"handle_ui\0"; -const METHOD_HANDLE_PEER: &[u8; 12] = b"handle_peer\0"; -pub const METHOD_HANDLE_LISTEN_EVENT: &[u8; 20] = b"handle_listen_event\0"; - -lazy_static::lazy_static! { - static ref PLUGIN_INFO: Arc>> = Default::default(); - static ref PLUGINS: Arc>> = Default::default(); -} - -pub(super) struct PluginInfo { - pub path: String, - pub uninstalled: bool, - pub desc: Desc, -} - -/// Initialize the plugins. -/// -/// data: The initialize data. -type PluginFuncInit = extern "C" fn(data: *const InitData) -> PluginReturn; -/// Reset the plugin. -/// -/// data: The initialize data. -type PluginFuncReset = extern "C" fn(data: *const InitData) -> PluginReturn; -/// Clear the plugin. -type PluginFuncClear = extern "C" fn() -> PluginReturn; -/// Get the description of the plugin. -/// Return the description. The plugin allocate memory with `libc::malloc` and return the pointer. -type PluginFuncDesc = extern "C" fn() -> *const c_char; -/// Callback to send message to peer or ui. -/// peer, target, id are utf8 strings(null terminated). -/// -/// peer: The peer id. -/// target: "peer" or "ui". -/// id: The id of this plugin. -/// content: The content. -/// len: The length of the content. -type CallbackMsg = extern "C" fn( - peer: *const c_char, - target: *const c_char, - id: *const c_char, - content: *const c_void, - len: usize, -) -> PluginReturn; -/// Callback to get the config. -/// peer, key are utf8 strings(null terminated). -/// -/// peer: The peer id. -/// id: The id of this plugin. -/// key: The key of the config. -/// -/// The returned string is utf8 string(null terminated) and must be freed by caller. -type CallbackGetConf = - extern "C" fn(peer: *const c_char, id: *const c_char, key: *const c_char) -> *const c_char; -/// Get local peer id. -/// -/// The returned string is utf8 string(null terminated) and must be freed by caller. -type CallbackGetId = extern "C" fn() -> *const c_char; -/// Callback to log. -/// -/// level, msg are utf8 strings(null terminated). -/// level: "error", "warn", "info", "debug", "trace". -/// msg: The message. -type CallbackLog = extern "C" fn(level: *const c_char, msg: *const c_char); - -/// Callback to the librustdesk core. -/// -/// method: the method name of this callback. -/// json: the json data for the parameters. The argument *must* be non-null. -/// raw: the binary data for this call, nullable. -/// raw_len: the length of this binary data, only valid when we pass raw data to `raw`. -type CallbackNative = extern "C" fn( - method: *const c_char, - json: *const c_char, - raw: *const c_void, - raw_len: usize, -) -> super::native::NativeReturnValue; -/// The main function of the plugin. -/// -/// method: The method. "handle_ui" or "handle_peer" -/// peer: The peer id. -/// args: The arguments. -/// len: The length of the arguments. -type PluginFuncCall = extern "C" fn( - method: *const c_char, - peer: *const c_char, - args: *const c_void, - len: usize, -) -> PluginReturn; -/// The main function of the plugin. -/// This function is called mainly for handling messages from the peer, -/// and then send messages back to the peer. -/// -/// method: The method. "handle_ui" or "handle_peer" -/// peer: The peer id. -/// args: The arguments. -/// len: The length of the arguments. -/// out: The output. -/// The plugin allocate memory with `libc::malloc` and return the pointer. -/// out_len: The length of the output. -type PluginFuncCallWithOutData = extern "C" fn( - method: *const c_char, - peer: *const c_char, - args: *const c_void, - len: usize, - out: *mut *mut c_void, - out_len: *mut usize, -) -> PluginReturn; - -/// The plugin callbacks. -/// msg: The callback to send message to peer or ui. -/// get_conf: The callback to get the config. -/// log: The callback to log. -#[repr(C)] -#[derive(Copy, Clone)] -struct Callbacks { - msg: CallbackMsg, - get_conf: CallbackGetConf, - get_id: CallbackGetId, - log: CallbackLog, - native: CallbackNative, -} - -#[derive(Serialize)] -#[repr(C)] -struct InitInfo { - is_server: bool, -} - -/// The plugin initialize data. -/// version: The version of the plugin, can't be nullptr. -/// local_peer_id: The local peer id, can't be nullptr. -/// cbs: The callbacks. -#[repr(C)] -struct InitData { - version: *const c_char, - info: *const c_char, - cbs: Callbacks, -} - -impl Drop for InitData { - fn drop(&mut self) { - free_c_ptr(self.version as _); - free_c_ptr(self.info as _); - } -} - -macro_rules! make_plugin { - ($($field:ident : $tp:ty),+) => { - #[allow(dead_code)] - pub struct Plugin { - _lib: Library, - id: Option, - path: String, - $($field: $tp),+ - } - - impl Plugin { - fn new(path: &str) -> ResultType { - let lib = match Library::open(path) { - Ok(lib) => lib, - Err(e) => { - bail!("Failed to load library {}, {}", path, e); - } - }; - - $(let $field = match unsafe { lib.symbol::<$tp>(stringify!($field)) } { - Ok(m) => { - *m - }, - Err(e) => { - bail!("Failed to load {} func {}, {}", path, stringify!($field), e); - } - } - ;)+ - - Ok(Self { - _lib: lib, - id: None, - path: path.to_string(), - $( $field ),+ - }) - } - - fn desc(&self) -> ResultType { - let desc_ret = (self.desc)(); - let desc = Desc::from_cstr(desc_ret); - free_c_ptr(desc_ret as _); - desc - } - - fn init(&self, data: &InitData, path: &str) -> ResultType<()> { - let mut init_ret = (self.init)(data as _); - if !init_ret.is_success() { - let (code, msg) = init_ret.get_code_msg(path); - bail!( - "Failed to init plugin {}, code: {}, msg: {}", - path, - code, - msg - ); - } - Ok(()) - } - - fn clear(&self, id: &str) { - let mut clear_ret = (self.clear)(); - if !clear_ret.is_success() { - let (code, msg) = clear_ret.get_code_msg(id); - log::error!( - "Failed to clear plugin {}, code: {}, msg: {}", - id, - code, - msg - ); - } - } - } - - impl Drop for Plugin { - fn drop(&mut self) { - let id = self.id.as_ref().unwrap_or(&self.path); - self.clear(id); - } - } - } -} - -make_plugin!( - init: PluginFuncInit, - reset: PluginFuncReset, - clear: PluginFuncClear, - desc: PluginFuncDesc, - call: PluginFuncCall, - call_with_out_data: PluginFuncCallWithOutData -); - -#[derive(Serialize)] -pub struct MsgListenEvent { - pub event: String, -} - -#[cfg(target_os = "windows")] -const DYLIB_SUFFIX: &str = ".dll"; -#[cfg(target_os = "linux")] -const DYLIB_SUFFIX: &str = ".so"; -#[cfg(target_os = "macos")] -const DYLIB_SUFFIX: &str = ".dylib"; - -pub(super) fn load_plugins(uninstalled_ids: &HashSet) -> ResultType<()> { - let plugins_dir = super::get_plugins_dir()?; - if !plugins_dir.exists() { - std::fs::create_dir_all(&plugins_dir)?; - } else { - for entry in std::fs::read_dir(plugins_dir)? { - match entry { - Ok(entry) => { - let plugin_dir = entry.path(); - if plugin_dir.is_dir() { - if let Some(plugin_id) = plugin_dir.file_name().and_then(|f| f.to_str()) { - if uninstalled_ids.contains(plugin_id) { - log::debug!( - "Ignore loading '{}' as it should be uninstalled", - plugin_id - ); - continue; - } - load_plugin_dir(&plugin_dir); - } - } - } - Err(e) => { - log::error!("Failed to read plugins dir entry, {}", e); - } - } - } - } - Ok(()) -} - -fn load_plugin_dir(dir: &Path) { - log::debug!("Begin load plugin dir: {}", dir.display()); - if let Ok(rd) = std::fs::read_dir(dir) { - for entry in rd { - match entry { - Ok(entry) => { - let path = entry.path(); - if path.is_file() { - let filename = entry.file_name(); - let filename = filename.to_str().unwrap_or(""); - if filename.starts_with("plugin_") && filename.ends_with(DYLIB_SUFFIX) { - if let Some(path) = path.to_str() { - if let Err(e) = load_plugin_path(path) { - log::error!("Failed to load plugin {}, {}", filename, e); - } - } - } - } - } - Err(e) => { - log::error!( - "Failed to read '{}' dir entry, {}", - dir.file_name().and_then(|f| f.to_str()).unwrap_or(""), - e - ); - } - } - } - } -} - -pub fn unload_plugin(id: &str) { - log::info!("Plugin {} unloaded", id); - PLUGINS.write().unwrap().remove(id); -} - -pub(super) fn mark_uninstalled(id: &str, uninstalled: bool) { - log::info!("Plugin {} uninstall", id); - PLUGIN_INFO - .write() - .unwrap() - .get_mut(id) - .map(|info| info.uninstalled = uninstalled); -} - -pub fn reload_plugin(id: &str) -> ResultType<()> { - let path = match PLUGIN_INFO.read().unwrap().get(id) { - Some(plugin) => plugin.path.clone(), - None => bail!("Plugin {} not found", id), - }; - unload_plugin(id); - load_plugin_path(&path) -} - -fn load_plugin_path(path: &str) -> ResultType<()> { - log::info!("Begin load plugin {}", path); - - let plugin = Plugin::new(path)?; - let desc = plugin.desc()?; - - // to-do validate plugin - // to-do check the plugin id (make sure it does not use another plugin's id) - - let id = desc.meta().id.clone(); - let plugin_info = PluginInfo { - path: path.to_string(), - uninstalled: false, - desc: desc.clone(), - }; - PLUGIN_INFO.write().unwrap().insert(id.clone(), plugin_info); - - let init_info = serde_json::to_string(&InitInfo { - is_server: super::is_server_running(), - })?; - let init_data = InitData { - version: str_to_cstr_ret(crate::VERSION), - info: str_to_cstr_ret(&init_info) as _, - cbs: Callbacks { - msg: callback_msg::cb_msg, - get_conf: config::cb_get_conf, - get_id: config::cb_get_local_peer_id, - log: super::plog::plugin_log, - native: super::native::cb_native_data, - }, - }; - // If do not load the plugin when init failed, the ui will not show the installed plugin. - if let Err(e) = plugin.init(&init_data, path) { - log::error!("Failed to init plugin '{}', {}", desc.meta().id, e); - } - - if super::is_server_running() { - super::config::ManagerConfig::add_plugin(&desc.meta().id)?; - } - - // update ui - // Ui may be not ready now, so we need to update again once ui is ready. - reload_ui(&desc, None); - - // add plugins - PLUGINS.write().unwrap().insert(id.clone(), plugin); - - log::info!("Plugin {} loaded, {}", id, path); - Ok(()) -} - -pub fn sync_ui(sync_to: String) { - for plugin in PLUGIN_INFO.read().unwrap().values() { - reload_ui(&plugin.desc, Some(&sync_to)); - } -} - -#[inline] -pub fn load_plugin(id: &str) -> ResultType<()> { - load_plugin_dir(&super::get_plugin_dir(id)?); - Ok(()) -} - -#[inline] -fn handle_event(method: &[u8], id: &str, peer: &str, event: &[u8]) -> ResultType<()> { - let mut peer: String = peer.to_owned(); - peer.push('\0'); - plugin_call(id, method, &peer, event) -} - -pub fn plugin_call(id: &str, method: &[u8], peer: &str, event: &[u8]) -> ResultType<()> { - let mut ret = plugin_call_get_return(id, method, peer, event)?; - if ret.is_success() { - Ok(()) - } else { - let (code, msg) = ret.get_code_msg(id); - bail!( - "Failed to handle plugin event, id: {}, method: {}, code: {}, msg: {}", - id, - std::string::String::from_utf8(method.to_vec()).unwrap_or_default(), - code, - msg - ); - } -} - -#[inline] -pub fn plugin_call_get_return( - id: &str, - method: &[u8], - peer: &str, - event: &[u8], -) -> ResultType { - match PLUGINS.read().unwrap().get(id) { - Some(plugin) => Ok((plugin.call)( - method.as_ptr() as _, - peer.as_ptr() as _, - event.as_ptr() as _, - event.len(), - )), - None => bail!("Plugin {} not found", id), - } -} - -#[inline] -pub fn handle_ui_event(id: &str, peer: &str, event: &[u8]) -> ResultType<()> { - handle_event(METHOD_HANDLE_UI, id, peer, event) -} - -#[inline] -pub fn handle_server_event(id: &str, peer: &str, event: &[u8]) -> ResultType<()> { - handle_event(METHOD_HANDLE_PEER, id, peer, event) -} - -fn _handle_listen_event(event: String, peer: String) { - let mut plugins = Vec::new(); - for info in PLUGIN_INFO.read().unwrap().values() { - if info.desc.listen_events().contains(&event.to_string()) { - plugins.push(info.desc.meta().id.clone()); - } - } - - if plugins.is_empty() { - return; - } - - if let Ok(evt) = serde_json::to_string(&MsgListenEvent { - event: event.clone(), - }) { - let mut evt_bytes = evt.as_bytes().to_vec(); - evt_bytes.push(0); - let mut peer: String = peer.to_owned(); - peer.push('\0'); - for id in plugins { - match PLUGINS.read().unwrap().get(&id) { - Some(plugin) => { - let mut ret = (plugin.call)( - METHOD_HANDLE_LISTEN_EVENT.as_ptr() as _, - peer.as_ptr() as _, - evt_bytes.as_ptr() as _, - evt_bytes.len(), - ); - if !ret.is_success() { - let (code, msg) = ret.get_code_msg(&id); - log::error!( - "Failed to handle plugin listen event, id: {}, event: {}, code: {}, msg: {}", - id, - event, - code, - msg - ); - } - } - None => { - log::error!("Plugin {} not found when handle_listen_event", id); - } - } - } - } -} - -#[inline] -pub fn handle_listen_event(event: String, peer: String) { - std::thread::spawn(|| _handle_listen_event(event, peer)); -} - -#[inline] -pub fn handle_client_event(id: &str, peer: &str, event: &[u8]) -> Message { - let mut peer: String = peer.to_owned(); - peer.push('\0'); - match PLUGINS.read().unwrap().get(id) { - Some(plugin) => { - let mut out = std::ptr::null_mut(); - let mut out_len: usize = 0; - let mut ret = (plugin.call_with_out_data)( - METHOD_HANDLE_PEER.as_ptr() as _, - peer.as_ptr() as _, - event.as_ptr() as _, - event.len(), - &mut out as _, - &mut out_len as _, - ); - if ret.is_success() { - let msg = make_plugin_request(id, out, out_len); - free_c_ptr(out as _); - msg - } else { - let (code, msg) = ret.get_code_msg(id); - if code > ERR_RUSTDESK_HANDLE_BASE && code < ERR_PLUGIN_HANDLE_BASE { - log::debug!( - "Plugin {} failed to handle client event, code: {}, msg: {}", - id, - code, - msg - ); - let name = match PLUGIN_INFO.read().unwrap().get(id) { - Some(plugin) => &plugin.desc.meta().name, - None => "???", - } - .to_owned(); - match code { - ERR_CALL_NOT_SUPPORTED_METHOD => { - make_plugin_failure(id, &name, "Plugin method is not supported") - } - ERR_CALL_INVALID_ARGS => { - make_plugin_failure(id, &name, "Plugin arguments is invalid") - } - _ => make_plugin_failure(id, &name, &msg), - } - } else { - log::error!( - "Plugin {} failed to handle client event, code: {}, msg: {}", - id, - code, - msg - ); - let msg = make_plugin_request(id, out, out_len); - free_c_ptr(out as _); - msg - } - } - } - None => make_plugin_failure(id, "", "Plugin not found"), - } -} - -fn make_plugin_request(id: &str, content: *const c_void, len: usize) -> Message { - let mut misc = Misc::new(); - misc.set_plugin_request(PluginRequest { - id: id.to_owned(), - content: unsafe { std::slice::from_raw_parts(content as *const u8, len) } - .clone() - .into(), - ..Default::default() - }); - let mut msg_out = Message::new(); - msg_out.set_misc(misc); - msg_out -} - -fn make_plugin_failure(id: &str, name: &str, msg: &str) -> Message { - let mut misc = Misc::new(); - misc.set_plugin_failure(PluginFailure { - id: id.to_owned(), - name: name.to_owned(), - msg: msg.to_owned(), - ..Default::default() - }); - let mut msg_out = Message::new(); - msg_out.set_misc(misc); - msg_out -} - -fn reload_ui(desc: &Desc, sync_to: Option<&str>) { - for (location, ui) in desc.location().ui.iter() { - if let Ok(ui) = serde_json::to_string(&ui) { - let make_event = |ui: &str| { - let mut m = HashMap::new(); - m.insert("name", MSG_TO_UI_TYPE_PLUGIN_RELOAD); - m.insert("id", &desc.meta().id); - m.insert("location", &location); - // Do not depend on the "location" and plugin desc on the ui side. - // Send the ui field to ensure the ui is valid. - m.insert("ui", ui); - serde_json::to_string(&m).unwrap_or("".to_owned()) - }; - match sync_to { - Some(channel) => { - let _res = flutter::push_global_event(channel, make_event(&ui)); - } - None => { - let v: Vec<&str> = location.split('|').collect(); - // The first element is the "client" or "host". - // The second element is the "main", "remote", "cm", "file transfer", "port forward". - if v.len() >= 2 { - let available_channels = flutter::get_global_event_channels(); - if available_channels.contains(&v[1]) { - let _res = flutter::push_global_event(v[1], make_event(&ui)); - } - } - } - } - } - } -} - -pub(super) fn get_plugin_infos() -> Arc>> { - PLUGIN_INFO.clone() -} - -pub(super) fn get_desc_conf(id: &str) -> Option { - PLUGIN_INFO - .read() - .unwrap() - .get(id) - .map(|info| info.desc.config().clone()) -} - -pub(super) fn get_version(id: &str) -> Option { - PLUGIN_INFO - .read() - .unwrap() - .get(id) - .map(|info| info.desc.meta().version.clone()) -} diff --git a/src/server/connection.rs b/src/server/connection.rs index bf05d56bd..7dc41ecbb 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -163,43 +163,6 @@ pub static CLICK_TIME: AtomicI64 = AtomicI64::new(0); #[cfg(not(any(target_os = "android", target_os = "ios")))] pub static MOUSE_MOVE_TIME: AtomicI64 = AtomicI64::new(0); -#[cfg(all(feature = "flutter", feature = "plugin_framework"))] -#[cfg(not(any(target_os = "android", target_os = "ios")))] -lazy_static::lazy_static! { - static ref PLUGIN_BLOCK_INPUT_TXS: Arc>>> = Default::default(); - static ref PLUGIN_BLOCK_INPUT_TX_RX: (Arc>>, Arc>>) = { - let (tx, rx) = std_mpsc::channel(); - (Arc::new(Mutex::new(tx)), Arc::new(Mutex::new(rx))) - }; -} - -// Block input is required for some special cases, such as privacy mode. -#[cfg(all(feature = "flutter", feature = "plugin_framework"))] -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub fn plugin_block_input(peer: &str, block: bool) -> bool { - if let Some(tx) = PLUGIN_BLOCK_INPUT_TXS.lock().unwrap().get(peer) { - let _ = tx.send(if block { - MessageInput::BlockOnPlugin(peer.to_string()) - } else { - MessageInput::BlockOffPlugin(peer.to_string()) - }); - match PLUGIN_BLOCK_INPUT_TX_RX - .1 - .lock() - .unwrap() - .recv_timeout(std::time::Duration::from_millis(3_000)) - { - Ok(b) => b == block, - Err(..) => { - log::error!("plugin_block_input timeout"); - false - } - } - } else { - false - } -} - #[derive(Clone, Default)] pub struct ConnInner { id: i32, @@ -225,12 +188,6 @@ enum MessageInput { Pointer((PointerDeviceEvent, i32)), BlockOn, BlockOff, - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - BlockOnPlugin(String), - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - BlockOffPlugin(String), } #[derive(Clone, Debug, Hash, Eq, PartialEq)] @@ -1176,12 +1133,6 @@ impl Connection { let _ = Self::turn_off_privacy_to_msg(id, String::new()); } } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - crate::plugin::handle_listen_event( - crate::plugin::EVENT_ON_CONN_CLOSE_SERVER.to_owned(), - conn.lr.my_id.clone(), - ); video_service::notify_video_frame_fetched_by_conn_id(id, None); if conn.authorized { password::update_temporary_password(); @@ -1266,32 +1217,6 @@ impl Connection { ); } } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - MessageInput::BlockOnPlugin(_peer) => { - let (ok, _msg) = crate::platform::block_input(true); - if ok { - block_input_mode = true; - } - let _r = PLUGIN_BLOCK_INPUT_TX_RX - .0 - .lock() - .unwrap() - .send(block_input_mode); - } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - MessageInput::BlockOffPlugin(_peer) => { - let (ok, _msg) = crate::platform::block_input(false); - if ok { - block_input_mode = false; - } - let _r = PLUGIN_BLOCK_INPUT_TX_RX - .0 - .lock() - .unwrap() - .send(block_input_mode); - } }, Err(err) => { #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -2032,13 +1957,6 @@ impl Connection { username = "".to_owned(); } } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - PLUGIN_BLOCK_INPUT_TXS - .lock() - .unwrap() - .insert(self.lr.my_id.clone(), self.tx_input.clone()); - // Terminal feature is supported on desktop only #[allow(unused_mut)] let mut terminal = cfg!(not(any(target_os = "android", target_os = "ios"))); @@ -3904,13 +3822,6 @@ impl Connection { self.change_resolution(Some(dr.display as _), &dr.resolution); } } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - Some(misc::Union::PluginRequest(p)) => { - let msg = - crate::plugin::handle_client_event(&p.id, &self.lr.my_id, &p.content); - self.send(msg).await; - } Some(misc::Union::AutoAdjustFps(fps)) => video_service::VIDEO_QOS .lock() .unwrap() diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index 9e4128dca..03b59a497 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -569,16 +569,6 @@ impl Session { self.send(Data::Message(msg)); } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - pub fn send_plugin_request(&self, request: PluginRequest) { - let mut misc = Misc::new(); - misc.set_plugin_request(request); - let mut msg_out = Message::new(); - msg_out.set_misc(misc); - self.send(Data::Message(msg_out)); - } - pub fn get_audit_server(&self, typ: String) -> String { if LocalConfig::get_option("access_token").is_empty() { return "".to_owned(); From 7aa98d43cf1962a7a29ec16ffef42974377ef11e Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:52:03 +0800 Subject: [PATCH 25/72] Refact/plugin removal leftovers (#15864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(flutter): dispose the settings PageController and order dispose() correctly `dispose()` began with `super.dispose()`, so the mixin chain marked the State defunct before the WidgetsBindingObserver registration and the periodic timer were released. The `PageController` was never disposed at all: `Get.delete` only runs `onDelete()` for a `GetLifeCycleBase`, and a plain `ChangeNotifier` is not one, so every open/close of the Settings tab leaked one controller with its listener still attached. Also guard `switch2page` on the `Rx` registration it actually reads rather than only the `PageController` — now that both are really deleted, a partial teardown would throw into the catch and silently open the wrong tab — and re-check `mounted` after the await in the `_videoConnTimer` tick, which `Timer::cancel` cannot stop once the body has started. Co-Authored-By: Claude Opus 5 (1M context) * refact: finish the plugin-framework removal sweep #15854 removed the feature but stopped short of its leftovers: - `Uninstall`, `Enable`, `Disable`, `Options` and `Please install plugins` were consumed only by the deleted `flutter/lib/plugin/**`; drop them from template.rs and the 50 locale files (250 dead entries). `Update` and `Install` stay, still used by desktop_home_page.dart. - The server no longer sends `PrvOnFailedPlugin`, and the client no longer offers to install plugins when privacy mode fails to turn on. - Drop the MSI `F_Client_Plugins` / `F_Server_Plugins` localization strings; no `.wxs` references them. - `_DisplayMenu`'s constructor became a pure pass-through once `pluginItem` was removed, and the cfg inside `handle_input` repeats the one on the function itself. - Normalize `src/lang/sl.rs` to 0644, the only executable file under src/. Co-Authored-By: Claude Opus 5 (1M context) * fix(client): handle legacy privacy mode plugin failures Signed-off-by: fufesou --------- Signed-off-by: fufesou Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: fufesou --- .../lib/desktop/pages/desktop_setting_page.dart | 17 ++++++++++++----- flutter/lib/desktop/widgets/remote_toolbar.dart | 8 +++----- res/msi/Package/Language/Package.en-us.wxl | 4 ---- src/client/io_loop.rs | 8 ++------ src/lang/ar.rs | 5 ----- src/lang/be.rs | 5 ----- src/lang/bg.rs | 5 ----- src/lang/ca.rs | 5 ----- src/lang/cn.rs | 5 ----- src/lang/cs.rs | 5 ----- src/lang/da.rs | 5 ----- src/lang/de.rs | 5 ----- src/lang/el.rs | 5 ----- src/lang/eo.rs | 5 ----- src/lang/es.rs | 5 ----- src/lang/et.rs | 5 ----- src/lang/eu.rs | 5 ----- src/lang/fa.rs | 5 ----- src/lang/fi.rs | 5 ----- src/lang/fr.rs | 5 ----- src/lang/ge.rs | 5 ----- src/lang/gu.rs | 5 ----- src/lang/he.rs | 5 ----- src/lang/hi.rs | 5 ----- src/lang/hr.rs | 5 ----- src/lang/hu.rs | 5 ----- src/lang/id.rs | 5 ----- src/lang/it.rs | 5 ----- src/lang/ja.rs | 5 ----- src/lang/ko.rs | 5 ----- src/lang/kz.rs | 5 ----- src/lang/lt.rs | 5 ----- src/lang/lv.rs | 5 ----- src/lang/ml.rs | 5 ----- src/lang/nb.rs | 5 ----- src/lang/nl.rs | 5 ----- src/lang/pl.rs | 5 ----- src/lang/pt_PT.rs | 5 ----- src/lang/ptbr.rs | 5 ----- src/lang/ro.rs | 5 ----- src/lang/ru.rs | 5 ----- src/lang/sc.rs | 5 ----- src/lang/sk.rs | 5 ----- src/lang/sl.rs | 5 ----- src/lang/sq.rs | 5 ----- src/lang/sr.rs | 5 ----- src/lang/sv.rs | 5 ----- src/lang/ta.rs | 5 ----- src/lang/template.rs | 5 ----- src/lang/th.rs | 5 ----- src/lang/tr.rs | 5 ----- src/lang/tw.rs | 5 ----- src/lang/uk.rs | 5 ----- src/lang/vi.rs | 5 ----- src/server/connection.rs | 3 +-- 55 files changed, 18 insertions(+), 272 deletions(-) mode change 100755 => 100644 src/lang/sl.rs diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index a2eb94e42..a67facfa9 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -90,7 +90,8 @@ class DesktopSettingPage extends StatefulWidget { if (index == -1) { return; } - if (Get.isRegistered(tag: _kSettingPageControllerTag)) { + if (Get.isRegistered(tag: _kSettingPageControllerTag) && + Get.isRegistered>(tag: _kSettingPageTabKeyTag)) { DesktopTabPage.onAddSetting(initialPage: page); PageController controller = Get.find(tag: _kSettingPageControllerTag); @@ -158,17 +159,23 @@ class _DesktopSettingPageState extends State if (!mounted) { return; } - _canBeBlocked.value = await canBeBlocked(); + final blocked = await canBeBlocked(); + if (!mounted) { + return; + } + _canBeBlocked.value = blocked; }); } @override void dispose() { - super.dispose(); + _videoConnTimer?.cancel(); + WidgetsBinding.instance.removeObserver(this); Get.delete(tag: _kSettingPageControllerTag); Get.delete>(tag: _kSettingPageTabKeyTag); - WidgetsBinding.instance.removeObserver(this); - _videoConnTimer?.cancel(); + // Get.delete does not dispose a plain ChangeNotifier. + controller.dispose(); + super.dispose(); } List<_TabInfo> _settingTabs() { diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 0516608cd..2627627a6 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -1476,13 +1476,11 @@ class _DisplayMenu extends StatefulWidget { final FFI ffi; final ToolbarState state; final Function(bool) setFullscreen; - _DisplayMenu( - {Key? key, - required this.id, + const _DisplayMenu( + {required this.id, required this.ffi, required this.state, - required this.setFullscreen}) - : super(key: key); + required this.setFullscreen}); @override State<_DisplayMenu> createState() => _DisplayMenuState(); diff --git a/res/msi/Package/Language/Package.en-us.wxl b/res/msi/Package/Language/Package.en-us.wxl index c65a5126d..74919e04d 100644 --- a/res/msi/Package/Language/Package.en-us.wxl +++ b/res/msi/Package/Language/Package.en-us.wxl @@ -21,8 +21,6 @@ This file contains the declaration of all the localizable strings. - - @@ -35,8 +33,6 @@ This file contains the declaration of all the localizable strings. - - diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index bc1828fd8..33ee93357 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -2260,12 +2260,8 @@ impl Remote { .msgbox("custom-error", "Privacy mode", "Peer denied", ""); self.update_privacy_mode(impl_key, false); } - back_notification::PrivacyModeState::PrvOnFailedPlugin => { - self.handler - .msgbox("custom-error", "Privacy mode", "Please install plugins", ""); - self.update_privacy_mode(impl_key, false); - } - back_notification::PrivacyModeState::PrvOnFailed => { + back_notification::PrivacyModeState::PrvOnFailedPlugin + | back_notification::PrivacyModeState::PrvOnFailed => { self.handler.msgbox( "custom-error", "Privacy mode", diff --git a/src/lang/ar.rs b/src/lang/ar.rs index bc9ea67d8..f66beca8f 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "شخص ما فعل وضع الخصوصية, خروج"), ("Unsupported", "غير مدعوم"), ("Peer denied", "القرين رفض"), - ("Please install plugins", "الرجاء تثبيت الاضافات"), ("Peer exit", "خروج القرين"), ("Failed to turn off", "فشل ايقاف التشغيل"), ("Turned off", "مطفئ"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "البصمة"), ("Copy Fingerprint", "نسخ البصمة"), ("no fingerprints", "لا توجد بصمات اصابع"), - ("Uninstall", "الغاء التثبيت"), ("Update", "تحديث"), - ("Enable", "تفعيل"), - ("Disable", "تعطيل"), - ("Options", "الخيارات"), ("resolution_original_tip", "الدقة الأصلية"), ("resolution_fit_local_tip", "تناسب الدقة المحلية"), ("resolution_custom_tip", "دقة مخصصة"), diff --git a/src/lang/be.rs b/src/lang/be.rs index 3dd0ef75e..411ea6ec5 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Хтосьці ўключыў рэжым канфідэнцыйнасці, выхад"), ("Unsupported", "Не падтрымліваецца"), ("Peer denied", "Забаронена абанентам"), - ("Please install plugins", "Усталюйце ўбудовы"), ("Peer exit", "Абанент выйшаў"), ("Failed to turn off", "Немагчыма выключыць"), ("Turned off", "Выключаны"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Адбітак"), ("Copy Fingerprint", "Капіяваць адбітак"), ("no fingerprints", "адбіткі адсутнічаюць"), - ("Uninstall", "Выдаліць"), ("Update", "Абнавіць"), - ("Enable", "Уключыць"), - ("Disable", "Адключыць"), - ("Options", "Параметры"), ("resolution_original_tip", "Арыгінальная раздзяляльнасць"), ("resolution_fit_local_tip", "Супадзенне з лакальнай раздзяляльнасцю"), ("resolution_custom_tip", "Карыстацкая раздзяляльнасць"), diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 0d926db31..b56334f6d 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Някой включва режим на поверителност, изход"), ("Unsupported", "Неподдържан"), ("Peer denied", "Отказ от другата страна"), - ("Please install plugins", "Моля поставете плъгини"), ("Peer exit", "Изход от другата страна"), ("Failed to turn off", "Неуспешен опит за изключване"), ("Turned off", "Изкключен"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Пръстов отпечатък"), ("Copy Fingerprint", "Копиране на пръстов отпечатък"), ("no fingerprints", "Няма пръстови отпечатъци"), - ("Uninstall", "Премахни"), ("Update", "Обновяване"), - ("Enable", "Позволяване"), - ("Disable", "Забрана"), - ("Options", "Настроики"), ("resolution_original_tip", "Оригинална разделителна способност"), ("resolution_fit_local_tip", "Приспособяване към тукашната разделителна способност"), ("resolution_custom_tip", "Разделителна способност по свой избор"), diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 17f5817b2..1412a2b76 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "S'ha activat el Mode privat; surt"), ("Unsupported", "No suportat"), ("Peer denied", "Client denegat"), - ("Please install plugins", "Instal·leu els complements"), ("Peer exit", "Finalitzat pel client"), ("Failed to turn off", "Ha fallat en desactivar"), ("Turned off", "Desactivat"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Empremta"), ("Copy Fingerprint", "Copia l'empremta"), ("no fingerprints", "Cap empremta"), - ("Uninstall", "Desinstal·la"), ("Update", "Actualitza"), - ("Enable", "Activa"), - ("Disable", "Desactiva"), - ("Options", "Opcions"), ("resolution_original_tip", "Resolució original"), ("resolution_fit_local_tip", "Ajusta la resolució local"), ("resolution_custom_tip", "Resolució personalitzada"), diff --git a/src/lang/cn.rs b/src/lang/cn.rs index d1ada573b..e685554b4 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "其他用户使用隐私模式,退出"), ("Unsupported", "不支持"), ("Peer denied", "被控端拒绝"), - ("Please install plugins", "请安装插件"), ("Peer exit", "被控端退出"), ("Failed to turn off", "退出失败"), ("Turned off", "退出"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "指纹"), ("Copy Fingerprint", "复制指纹"), ("no fingerprints", "没有指纹"), - ("Uninstall", "卸载"), ("Update", "更新"), - ("Enable", "启用"), - ("Disable", "禁用"), - ("Options", "选项"), ("resolution_original_tip", "原始分辨率"), ("resolution_fit_local_tip", "适应本地分辨率"), ("resolution_custom_tip", "自定义分辨率"), diff --git a/src/lang/cs.rs b/src/lang/cs.rs index 3bb59ecfb..7bf85ec49 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Někdo zapne režim ochrany soukromí, ukončete ho"), ("Unsupported", "Nepodporováno"), ("Peer denied", "Protistrana odmítla"), - ("Please install plugins", "Nainstalujte si prosím pluginy"), ("Peer exit", "Ukončení protistrany"), ("Failed to turn off", "Nepodařilo se vypnout"), ("Turned off", "Vypnutý"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Otisk"), ("Copy Fingerprint", "Kopírovat otisk"), ("no fingerprints", "žádný otisk"), - ("Uninstall", "Odinstalovat"), ("Update", "Aktualizovat"), - ("Enable", "Povolit"), - ("Disable", "Zakázat"), - ("Options", "Možnosti"), ("resolution_original_tip", "Původní rozlišení"), ("resolution_fit_local_tip", "Přizpůsobit místní rozlišení"), ("resolution_custom_tip", "Vlastní rozlišení"), diff --git a/src/lang/da.rs b/src/lang/da.rs index 823a58a6b..d8d9caaf6 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Nogen aktiverede privatlivstilstand, afslut"), ("Unsupported", "Ikke understøttet"), ("Peer denied", "Modpart nægtet"), - ("Please install plugins", "Installer venligst plugins"), ("Peer exit", "Modpart-Afslut"), ("Failed to turn off", "Mislykkedes i at lukke ned"), ("Turned off", "Slukket"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingeraftryk"), ("Copy Fingerprint", "Kopiér fingeraftryk"), ("no fingerprints", "Ingen fingeraftryk"), - ("Uninstall", "Afinstallér"), ("Update", "Opdatér"), - ("Enable", "Aktivér"), - ("Disable", "Deaktivér"), - ("Options", "Valgmuligheder"), ("resolution_original_tip", "Original skærmopløsning"), ("resolution_fit_local_tip", "Tilpas lokal skærmopløsning"), ("resolution_custom_tip", "Bruger-tilpasset skærmopløsning"), diff --git a/src/lang/de.rs b/src/lang/de.rs index 7f30d1051..44da8446b 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Jemand hat den Datenschutzmodus aktiviert, wird beendet …"), ("Unsupported", "Nicht unterstützt"), ("Peer denied", "Die Gegenstelle hat die Verbindung abgelehnt."), - ("Please install plugins", "Bitte installieren Sie Plugins"), ("Peer exit", "Die Gegenstelle hat die Verbindung getrennt."), ("Failed to turn off", "Ausschalten fehlgeschlagen"), ("Turned off", "Ausgeschaltet"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingerabdruck"), ("Copy Fingerprint", "Fingerabdruck kopieren"), ("no fingerprints", "Keine Fingerabdrücke"), - ("Uninstall", "Deinstallieren"), ("Update", "Update"), - ("Enable", "Aktivieren"), - ("Disable", "Deaktivieren"), - ("Options", "Einstellungen"), ("resolution_original_tip", "Originale Auflösung"), ("resolution_fit_local_tip", "Lokale Auflösung anpassen"), ("resolution_custom_tip", "Benutzerdefinierte Auflösung"), diff --git a/src/lang/el.rs b/src/lang/el.rs index e6c4ab6fe..d6f96fa3c 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Κάποιος ενεργοποιεί τη λειτουργία απορρήτου, έξοδος"), ("Unsupported", "Δεν υποστηρίζεται"), ("Peer denied", "Ο απομακρυσμένος σταθμός έχει απορριφθεί"), - ("Please install plugins", "Παρακαλώ εγκαταστήστε τα πρόσθετα"), ("Peer exit", "Ο απομακρυσμένος σταθμός έχει αποσυνδεθεί"), ("Failed to turn off", "Αποτυχία απενεργοποίησης"), ("Turned off", "Απενεργοποιημένο"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Δακτυλικό αποτύπωμα"), ("Copy Fingerprint", "Αντιγραφή δακτυλικού αποτυπώματος"), ("no fingerprints", "χωρίς δακτυλικά αποτυπώματα"), - ("Uninstall", "Κατάργηση εγκατάστασης"), ("Update", "Ενημέρωση"), - ("Enable", "Ενεργοποίηση"), - ("Disable", "Απενεργοποίηση"), - ("Options", "Επιλογές"), ("resolution_original_tip", "Αρχική ανάλυση"), ("resolution_fit_local_tip", "Προσαρμογή στην τοπική ανάλυση"), ("resolution_custom_tip", "Προσαρμοσμένη ανάλυση"), diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 5e5c19d64..f7048783c 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Iu ŝaltas modon privata, Eliro"), ("Unsupported", "Nesubtenata"), ("Peer denied", "Samulo rifuzita"), - ("Please install plugins", "Bonvolu instali kromprogramojn"), ("Peer exit", "Samulo eliras"), ("Failed to turn off", "Malsukcesis malŝalti"), ("Turned off", "Malŝaltita"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingrospuro"), ("Copy Fingerprint", "Kopii fingrospuron"), ("no fingerprints", "Neniuj fingrospuroj"), - ("Uninstall", "Malinstali"), ("Update", "Ĝisdatigi"), - ("Enable", "Ebligi"), - ("Disable", "Malebligi"), - ("Options", "Opcioj"), ("resolution_original_tip", "Originala distingivo"), ("resolution_fit_local_tip", "Adapti al loka distingivo"), ("resolution_custom_tip", "Propra distingivo"), diff --git a/src/lang/es.rs b/src/lang/es.rs index 7f12ef41e..3285a71e0 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Alguien active el modo privacidad, salga"), ("Unsupported", "No soportado"), ("Peer denied", "Par denegado"), - ("Please install plugins", "Instale complementos"), ("Peer exit", "Par salio"), ("Failed to turn off", "Error al apagar"), ("Turned off", "Apagado"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Huella digital"), ("Copy Fingerprint", "Copiar huella digital"), ("no fingerprints", "sin huellas digitales"), - ("Uninstall", "Desinstalar"), ("Update", "Actualizar"), - ("Enable", "Habilitar"), - ("Disable", "Inhabilitar"), - ("Options", "Opciones"), ("resolution_original_tip", "Resolución original"), ("resolution_fit_local_tip", "Ajustar resolución local"), ("resolution_custom_tip", "Resolución personalizada"), diff --git a/src/lang/et.rs b/src/lang/et.rs index d8cd510bb..a97ad97ff 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Keegi lülitab sisse privaatsusrežiimi, välju"), ("Unsupported", "Mittetoetatud"), ("Peer denied", "Partner keeldus"), - ("Please install plugins", "Palun paigalda pluginad"), ("Peer exit", "Partner väljub"), ("Failed to turn off", "Väljalülitamine ebaõnnestus"), ("Turned off", "Väljalülitatud"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Sõrmejälg"), ("Copy Fingerprint", "Kopeeri sõrmejälg"), ("no fingerprints", "Sõrmejäljed puuduvad"), - ("Uninstall", "Desinstalli"), ("Update", "Uuenda"), - ("Enable", "Luba"), - ("Disable", "Keela"), - ("Options", "Valikud"), ("resolution_original_tip", "Originaalne eraldusvõime"), ("resolution_fit_local_tip", "Ühita kohaliku eraldusvõimega"), ("resolution_custom_tip", "Kohandatud eraldusvõime"), diff --git a/src/lang/eu.rs b/src/lang/eu.rs index 838b26353..ef534828f 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Norbaitek pribatutasun modua hasten du, irten"), ("Unsupported", "Ez da onartzen"), ("Peer denied", "Parekidea ukatuta"), - ("Please install plugins", "Mesedez, instalatu plugin hauek"), ("Peer exit", "Parekidea irten da"), ("Failed to turn off", "Itzaltzeak huts egin du"), ("Turned off", "Itzalita"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Hatz-marka"), ("Copy Fingerprint", "Kopiatu hatz-marka"), ("no fingerprints", "hatz-markarik ez"), - ("Uninstall", "Desinstalatu"), ("Update", "Eguneratu"), - ("Enable", "Gaitu"), - ("Disable", "Desgaitu"), - ("Options", "Aukerak"), ("resolution_original_tip", "Jatorrizko bereizmena"), ("resolution_fit_local_tip", "Bereizmen lokala egokitu"), ("resolution_custom_tip", "Bereizmen pertsonalizatua"), diff --git a/src/lang/fa.rs b/src/lang/fa.rs index 08dc9f568..7b01a1a7b 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "اگر شخصی حالت حریم خصوصی را روشن کرد، خارج شوید"), ("Unsupported", "پشتیبانی نشده"), ("Peer denied", "توسط میزبان راه دور رد شد"), - ("Please install plugins", "لطفا افزونه ها را نصب کنید"), ("Peer exit", "میزبان خارج شد"), ("Failed to turn off", "خاموش کردن انجام نشد"), ("Turned off", "خاموش شد"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "\n اثر انگشت"), ("Copy Fingerprint", "کپی کردن اثر انگشت"), ("no fingerprints", "بدون اثر انگشت"), - ("Uninstall", "حذف نصب"), ("Update", "به روز رسانی"), - ("Enable", "فعال کردن"), - ("Disable", "غیر فعال کردن"), - ("Options", "گزینه ها"), ("resolution_original_tip", "وضوح اصلی"), ("resolution_fit_local_tip", "متناسب با وضوح محلی"), ("resolution_custom_tip", "وضوح سفارشی"), diff --git a/src/lang/fi.rs b/src/lang/fi.rs index d86cad0c7..b4bd3cb5b 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Yksityisyystila otettu käyttöön, poistutaan"), ("Unsupported", "Ei tuettu"), ("Peer denied", "Vastapuoli hylkäsi pyynnön"), - ("Please install plugins", "Asenna tarvittavat lisäosat"), ("Peer exit", "Vastapuoli sulki yhteyden"), ("Failed to turn off", "Sammutus epäonnistui"), ("Turned off", "Sammutettu"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Sormenjälki"), ("Copy Fingerprint", "Kopioi sormenjälki"), ("no fingerprints", "Ei sormenjälkiä"), - ("Uninstall", "Poista asennus"), ("Update", "Päivitä"), - ("Enable", "Ota käyttöön"), - ("Disable", "Poista käytöstä"), - ("Options", "Asetukset"), ("resolution_original_tip", "Näytä alkuperäisessä resoluutiossa ilman skaalausta"), ("resolution_fit_local_tip", "Sovita etänäyttö paikalliseen näkymään"), ("resolution_custom_tip", "Käytä mukautettua resoluutiota"), diff --git a/src/lang/fr.rs b/src/lang/fr.rs index a20c0e41b..9a4acf6f3 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Quelqu’un active le mode de confidentialité, désactiver"), ("Unsupported", "Non pris en charge"), ("Peer denied", "Refusé par l’appareil distant"), - ("Please install plugins", "Veuillez installer les plugins"), ("Peer exit", "Désactivé par l’appareil distant"), ("Failed to turn off", "Échec de la désactivation"), ("Turned off", "Désactivé"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Empreinte numérique"), ("Copy Fingerprint", "Copier l’empreinte numérique"), ("no fingerprints", "Aucune empreinte numérique"), - ("Uninstall", "Désinstaller"), ("Update", "Mettre à jour"), - ("Enable", "Activer"), - ("Disable", "Désactiver"), - ("Options", "Options"), ("resolution_original_tip", "Résolution d’origine"), ("resolution_fit_local_tip", "Adapter à la résolution locale"), ("resolution_custom_tip", "Résolution personnalisée"), diff --git a/src/lang/ge.rs b/src/lang/ge.rs index ce6e89bb4..f2d807c4b 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "ვიღაცამ ჩართო კონფიდენციალურობის რეჟიმი, გასვლა"), ("Unsupported", "არ არის მხარდაჭერილი"), ("Peer denied", "უარყოფილია დაშორებული კვანძის მიერ"), - ("Please install plugins", "დააინსტალირეთ პლაგინები"), ("Peer exit", "გათიშულია მომხმარებლის მიერ"), ("Failed to turn off", "გამორთვა შეუძლებელია"), ("Turned off", "გამორთული"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ანაბეჭდი"), ("Copy Fingerprint", "ანაბეჭდის კოპირება"), ("no fingerprints", "ანაბეჭდები არ არის"), - ("Uninstall", "წაშლა"), ("Update", "განახლება"), - ("Enable", "ჩართვა"), - ("Disable", "გამორთვა"), - ("Options", "პარამეტრები"), ("resolution_original_tip", "საწყისი გარჩევადობა"), ("resolution_fit_local_tip", "ლოკალური გარჩევადობის შესაბამისი"), ("resolution_custom_tip", "მორგებული გარჩევადობა"), diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 31e905ea7..28c4b7a89 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "કોઈએ પ્રાઇવસી મોડ ચાલુ કર્યો છે, બહાર નીકળો"), ("Unsupported", "અસમર્થિત"), ("Peer denied", "સામેથી નકારવામાં આવ્યું"), - ("Please install plugins", "કૃપા કરીને પ્લગઇન્સ ઇન્સ્ટોલ કરો"), ("Peer exit", "સામેથી કોઈ બહાર નીકળી ગયું"), ("Failed to turn off", "બંધ કરવામાં નિષ્ફળ"), ("Turned off", "બંધ કરવામાં આવ્યું"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ફિંગરપ્રિન્ટ"), ("Copy Fingerprint", "ફિંગરપ્રિન્ટ કોપી કરો"), ("no fingerprints", "કોઈ ફિંગરપ્રિન્ટ નથી"), - ("Uninstall", "અનઇન્સ્ટોલ કરો"), ("Update", "અપડેટ કરો"), - ("Enable", "સક્ષમ કરો"), - ("Disable", "અક્ષમ કરો"), - ("Options", "વિકલ્પો"), ("resolution_original_tip", "મૂળ રિઝોલ્યુશન"), ("resolution_fit_local_tip", "સ્ક્રીન મુજબ ફીટ કરો"), ("resolution_custom_tip", "કસ્ટમ રિઝોલ્યુશન"), diff --git a/src/lang/he.rs b/src/lang/he.rs index b82f66dab..e11826d01 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "מישהו הפעיל מצב פרטיות, מתבצעת יציאה"), ("Unsupported", "לא נתמך"), ("Peer denied", "הצד השני סירב"), - ("Please install plugins", "אנא התקן תוספים"), ("Peer exit", "הצד השני התנתק"), ("Failed to turn off", "הכיבוי נכשל"), ("Turned off", "מכובה"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "טביעת אצבע"), ("Copy Fingerprint", "העתק טביעת אצבע"), ("no fingerprints", "אין טביעות אצבע"), - ("Uninstall", "הסר"), ("Update", "עדכן"), - ("Enable", "פועל"), - ("Disable", "כבוי"), - ("Options", "אפשרויות"), ("resolution_original_tip", "רזולוציה מקורית"), ("resolution_fit_local_tip", "התאם לרזולוציה מקומית"), ("resolution_custom_tip", "רזולוציה מותאמת אישית"), diff --git a/src/lang/hi.rs b/src/lang/hi.rs index 1e5d3a0b5..0b1da4efe 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "किसी ने गोपनीयता मोड चालू किया है, बाहर निकल रहे हैं"), ("Unsupported", "असमर्थित"), ("Peer denied", "दूसरे सिस्टम ने मना कर दिया"), - ("Please install plugins", "कृपया प्लगइन्स इंस्टॉल करें"), ("Peer exit", "दूसरा सिस्टम बाहर निकल गया"), ("Failed to turn off", "बंद करने में विफल"), ("Turned off", "बंद कर दिया गया"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "फिंगरप्रिंट"), ("Copy Fingerprint", "फिंगरप्रिंट कॉपी करें"), ("no fingerprints", "कोई फिंगरप्रिंट नहीं"), - ("Uninstall", "अनइंस्टॉल करें"), ("Update", "अपडेट करें"), - ("Enable", "सक्षम करें"), - ("Disable", "अक्षम करें"), - ("Options", "विकल्प"), ("resolution_original_tip", "मूल रिज़ॉल्यूशन"), ("resolution_fit_local_tip", "स्थानीय स्क्रीन में फिट करें"), ("resolution_custom_tip", "कस्टम रिज़ॉल्यूशन"), diff --git a/src/lang/hr.rs b/src/lang/hr.rs index a05462292..d74ab784f 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Netko je uključio način privatnosti, izlaz."), ("Unsupported", "Nepodržano"), ("Peer denied", "Klijent zabranjen"), - ("Please install plugins", "Molimo instalirajte dodatke"), ("Peer exit", "Klijent je izašao"), ("Failed to turn off", "Greška kod isključenja"), ("Turned off", "Isključeno"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Otisak"), ("Copy Fingerprint", "Kopirat otisak"), ("no fingerprints", "nema otiska"), - ("Uninstall", "Deinstaliraj"), ("Update", "Ažuriraj"), - ("Enable", "Dopustiti"), - ("Disable", "Zabraniti"), - ("Options", "Mogućnosti"), ("resolution_original_tip", "Izvorna rezolucija"), ("resolution_fit_local_tip", "Podesite lokalnu rezoluciju"), ("resolution_custom_tip", "Prilagođena rezolucija"), diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 68d26c389..3244269b4 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Valaki bekacsolta az inkognitó módot, lépjen ki"), ("Unsupported", "Nem támogatott"), ("Peer denied", "Elutasítva a távoli fél által"), - ("Please install plugins", "Telepítse a bővítményeket"), ("Peer exit", "A távoli fél kilépett"), ("Failed to turn off", "Nem sikerült kikapcsolni"), ("Turned off", "Kikapcsolva"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Ujjlenyomat"), ("Copy Fingerprint", "Ujjlenyomat másolása"), ("no fingerprints", "nincsenek ujjlenyomatok"), - ("Uninstall", "Eltávolítás"), ("Update", "Frissítés"), - ("Enable", "Engedélyezés"), - ("Disable", "Letiltás"), - ("Options", "Opciók"), ("resolution_original_tip", "Eredeti felbontás"), ("resolution_fit_local_tip", "Helyi felbontás beállítása"), ("resolution_custom_tip", "Testre szabható felbontás"), diff --git a/src/lang/id.rs b/src/lang/id.rs index c51c908a4..594ea50f6 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Seseorang mengaktifkan mode privasi, keluar"), ("Unsupported", "Tidak didukung"), ("Peer denied", "Rekan menolak"), - ("Please install plugins", "Silakan instal plugin"), ("Peer exit", "Rekan keluar"), ("Failed to turn off", "Gagal mematikan"), ("Turned off", "Dimatikan"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Sidik jari"), ("Copy Fingerprint", "Salin sidik jari"), ("no fingerprints", "Tidak ada sidik jari"), - ("Uninstall", "Hapus instalasi"), ("Update", "Perbarui"), - ("Enable", "Aktifkan"), - ("Disable", "Nonaktifkan"), - ("Options", "Opsi"), ("resolution_original_tip", "Resolusi original"), ("resolution_fit_local_tip", "Sesuaikan resolusi lokal"), ("resolution_custom_tip", "Resolusi kustom"), diff --git a/src/lang/it.rs b/src/lang/it.rs index 939048e3b..9747a35c3 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Qualcuno ha attivato la modalità privacy, uscita"), ("Unsupported", "Non supportato"), ("Peer denied", "Accesso negato al dispositivo remoto"), - ("Please install plugins", "Installa i plugin"), ("Peer exit", "Uscita dal dispostivo remoto"), ("Failed to turn off", "Impossibile spegnere"), ("Turned off", "Spegni"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Firma digitale"), ("Copy Fingerprint", "Copia firma digitale"), ("no fingerprints", "Nessuna firma digitale"), - ("Uninstall", "Disinstalla"), ("Update", "Aggiorna"), - ("Enable", "Abilita"), - ("Disable", "Disabilita"), - ("Options", "Opzioni"), ("resolution_original_tip", "Risoluzione originale"), ("resolution_fit_local_tip", "Adatta risoluzione locale"), ("resolution_custom_tip", "Risoluzione personalizzata"), diff --git a/src/lang/ja.rs b/src/lang/ja.rs index 2d944bf8f..9ff8d2dc4 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "プライバシーモードがオンになりました。終了します。"), ("Unsupported", "対応していません"), ("Peer denied", "リモートホストに拒否されました"), - ("Please install plugins", "プラグインをインストールしてください"), ("Peer exit", "リモートホストが退出しました"), ("Failed to turn off", "オフにできませんでした"), ("Turned off", "オフになりました"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "フィンガープリント"), ("Copy Fingerprint", "フィンガープリントをコピー"), ("no fingerprints", "フィンガープリントがありません"), - ("Uninstall", "アンインストール"), ("Update", "更新"), - ("Enable", "有効"), - ("Disable", "無効"), - ("Options", "設定"), ("resolution_original_tip", "オリジナルの解像度"), ("resolution_fit_local_tip", "ローカル解像度に合わせる"), ("resolution_custom_tip", "カスタム解像度"), diff --git a/src/lang/ko.rs b/src/lang/ko.rs index d46c327d3..a55eb6695 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "누군가 개인정보 보호 모드를 켰습니다, 연결을 종료합니다"), ("Unsupported", "지원되지 않음"), ("Peer denied", "연결 거부됨"), - ("Please install plugins", "플러그인을 설치해주세요"), ("Peer exit", "피어 종료"), ("Failed to turn off", "끄기 실패"), ("Turned off", "꺼짐"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "지문"), ("Copy Fingerprint", "지문 복사"), ("no fingerprints", "지문이 없습니다"), - ("Uninstall", "설치 제거"), ("Update", "업데이트"), - ("Enable", "허용"), - ("Disable", "사용 안 함"), - ("Options", "옵션"), ("resolution_original_tip", "원본 해상도"), ("resolution_fit_local_tip", "로컬 화면에 맞춤"), ("resolution_custom_tip", "사용자 지정 해상도"), diff --git a/src/lang/kz.rs b/src/lang/kz.rs index b73b8dac0..998b74172 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Біреу құпиялылық модасын қосты, шығу"), ("Unsupported", "Қолдаусыз"), ("Peer denied", "Пир қабылдамады"), - ("Please install plugins", "Плагиндерді орнатуды өтінеміз"), ("Peer exit", "Пирдің шығуы"), ("Failed to turn off", "Сөндіру сәтсіз болды"), ("Turned off", "Өшірілген"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Саусақ ізі"), ("Copy Fingerprint", "Саусақ ізін көшіру"), ("no fingerprints", "Саусақ іздері жоқ"), - ("Uninstall", "Жою"), ("Update", "Жаңарту"), - ("Enable", "Қосу"), - ("Disable", "Өшіру"), - ("Options", "Опциялар"), ("resolution_original_tip", "Түпнұсқа ажыратымдылық"), ("resolution_fit_local_tip", "Лақал ажыратымдылыққа сыйғызу"), ("resolution_custom_tip", "Теңшеулі ажыратымдылық"), diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 5c26e3119..5611cc192 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Kažkas įjungė privatumo režimą, išeiti"), ("Unsupported", "Nepalaikomas"), ("Peer denied", "Atšaukė"), - ("Please install plugins", "Įdiekite papildinius"), ("Peer exit", "Nuotolinis mazgas neveikia"), ("Failed to turn off", "Nepavyko išjungti"), ("Turned off", "Išjungti"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Kontrolinis kodas"), ("Copy Fingerprint", "Kopijuoti kontrolinį kodą"), ("no fingerprints", "Nėra kontrolinių kodų"), - ("Uninstall", "Pašalinti"), ("Update", "Atnaujinti"), - ("Enable", "Įgalinti"), - ("Disable", "Išjungti"), - ("Options", "Parinktys"), ("resolution_original_tip", "Originali skiriamoji geba"), ("resolution_fit_local_tip", "Pritaikyti prie vietinės skiriamosios gebos"), ("resolution_custom_tip", "Tinkinta skiriamoji geba"), diff --git a/src/lang/lv.rs b/src/lang/lv.rs index 7ae317893..5ac325cb6 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Kāds ieslēdza privātuma režīmu, iziet"), ("Unsupported", "Neatbalstīts"), ("Peer denied", "Sesija noraidīta"), - ("Please install plugins", "Lūdzu, instalējiet spraudņus"), ("Peer exit", "Iziet no attālās ierīces"), ("Failed to turn off", "Neizdevās izslēgt"), ("Turned off", "Izslēgts"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Pirkstu nospiedums"), ("Copy Fingerprint", "Kopēt pirkstu nospiedumu"), ("no fingerprints", "nav pirkstu nospiedumu"), - ("Uninstall", "Atinstalēt"), ("Update", "Atjaunināt"), - ("Enable", "Iespējot"), - ("Disable", "Atspējot"), - ("Options", "Opcijas"), ("resolution_original_tip", "Sākotnējā izšķirtspēja"), ("resolution_fit_local_tip", "Atbilst vietējai izšķirtspējai"), ("resolution_custom_tip", "Pielāgota izšķirtspēja"), diff --git a/src/lang/ml.rs b/src/lang/ml.rs index 69394909b..c781d288a 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "ആരോ പ്രൈവസി മോഡ് ഓൺ ചെയ്തു, പുറത്തുകടക്കുന്നു"), ("Unsupported", "പിന്തുണയ്ക്കുന്നില്ല"), ("Peer denied", "മറുഭാഗത്തുനിന്ന് നിരസിച്ചു"), - ("Please install plugins", "ദയവായി പ്ലഗിനുകൾ ഇൻസ്റ്റാൾ ചെയ്യുക"), ("Peer exit", "മറുഭാഗത്തുനിന്ന് പുറത്തുകടന്നു"), ("Failed to turn off", "ഓഫ് ചെയ്യുന്നതിൽ പരാജയപ്പെട്ടു"), ("Turned off", "ഓഫ് ചെയ്തു"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ഫിംഗർപ്രിന്റ്"), ("Copy Fingerprint", "ഫിംഗർപ്രിന്റ് കോപ്പി ചെയ്യുക"), ("no fingerprints", "ഫിംഗർപ്രിന്റുകൾ ഇല്ല"), - ("Uninstall", "അൺഇൻസ്റ്റാൾ ചെയ്യുക"), ("Update", "അപ്ഡേറ്റ് ചെയ്യുക"), - ("Enable", "പ്രവർത്തനക്ഷമമാക്കുക"), - ("Disable", "പ്രവർത്തനരഹിതമാക്കുക"), - ("Options", "ഓപ്ഷനുകൾ"), ("resolution_original_tip", "ഒറിജിനൽ റെസല്യൂഷൻ"), ("resolution_fit_local_tip", "ലോക്കൽ സ്ക്രീനിന് അനുയോജ്യം"), ("resolution_custom_tip", "കസ്റ്റം റെസല്യൂഷൻ"), diff --git a/src/lang/nb.rs b/src/lang/nb.rs index cf0009314..ef26b87d6 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Noen aktiverte privatlivsmodus, avslutt"), ("Unsupported", "Ikke støttet"), ("Peer denied", "Motpart nektet"), - ("Please install plugins", "Installer plugins"), ("Peer exit", "Motpart-Avslutt"), ("Failed to turn off", "Klarte ikke å skru av"), ("Turned off", "Avslått"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingeravtrykk"), ("Copy Fingerprint", "Kopier fingeravtrykk"), ("no fingerprints", "Ingen fingeravtrykk"), - ("Uninstall", "Avinstaller"), ("Update", "Oppdater"), - ("Enable", "Aktiver"), - ("Disable", "Deaktiver"), - ("Options", "Alternativer"), ("resolution_original_tip", "Original oppløsning"), ("resolution_fit_local_tip", "Tilpass til lokal oppløsning"), ("resolution_custom_tip", "Tilpasset oppløsning"), diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 68206f0d6..e94d66c94 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Iemand schakelt privacymodus in, afsluiten"), ("Unsupported", "Niet ondersteund"), ("Peer denied", "Peer geweigerd"), - ("Please install plugins", "Plugins installeren"), ("Peer exit", "Peer afgesloten"), ("Failed to turn off", "Uitschakelen mislukt"), ("Turned off", "Uitgeschakeld"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Vingerafdruk"), ("Copy Fingerprint", "Vingerafdruk kopiëren"), ("no fingerprints", "geen vingerafdrukken"), - ("Uninstall", "Verwijderen"), ("Update", "Bijwerken"), - ("Enable", "Activeren"), - ("Disable", "Deactiveren"), - ("Options", "Opties"), ("resolution_original_tip", "Oorspronkelijke resolutie"), ("resolution_fit_local_tip", "Lokale resolutie aanpassen"), ("resolution_custom_tip", "Aangepaste resolutie"), diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 0e2e03f02..ea5bd47e5 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Ktoś włącza tryb prywatności, wyjdź"), ("Unsupported", "Niewspierane"), ("Peer denied", "Odmowa dostępu"), - ("Please install plugins", "Zainstaluj wtyczkę"), ("Peer exit", "Wyjście ze zdalnego urządzenia"), ("Failed to turn off", "Nie udało się wyłączyć"), ("Turned off", "Wyłączony"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Sygnatura"), ("Copy Fingerprint", "Skopiuj sygnaturę"), ("no fingerprints", "brak sygnatur"), - ("Uninstall", "Odinstaluj"), ("Update", "Aktualizuj"), - ("Enable", "Włącz"), - ("Disable", "Wyłącz"), - ("Options", "Opcje"), ("resolution_original_tip", "Oryginalna rozdzielczość"), ("resolution_fit_local_tip", "Dostosuj rozdzielczość lokalną"), ("resolution_custom_tip", "Rozdzielczość niestandardowa"), diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index a391fcfdc..e06b46559 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Alguém activou o modo de privacidade, desligue"), ("Unsupported", "Sem suporte"), ("Peer denied", "Remoto negado"), - ("Please install plugins", "Por favor instale plugins"), ("Peer exit", "Saída do Remoto"), ("Failed to turn off", "Falha ao desligar"), ("Turned off", "Desligado"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Impressão digital"), ("Copy Fingerprint", "Copiar impressão digital"), ("no fingerprints", "Sem impressões digitais"), - ("Uninstall", "Desinstalar"), ("Update", "Atualizar"), - ("Enable", "Ativar"), - ("Disable", "Desativar"), - ("Options", "Opções"), ("resolution_original_tip", "Resolução original"), ("resolution_fit_local_tip", "Ajustar à resolução local"), ("resolution_custom_tip", "Resolução personalizada"), diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 522b3f8b8..69adca61e 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Alguém habilitou o modo de privacidade, sair"), ("Unsupported", "Não suportado"), ("Peer denied", "Parceiro negou"), - ("Please install plugins", "Por favor instale plugins"), ("Peer exit", "Parceiro saiu"), ("Failed to turn off", "Falha ao desligar"), ("Turned off", "Desligado"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Impressão Digital"), ("Copy Fingerprint", "Copiar Impressão Digital"), ("no fingerprints", "sem Impressões Digitais"), - ("Uninstall", "Desinstalar"), ("Update", "Atualizar"), - ("Enable", "Habilitar"), - ("Disable", "Desabilitar"), - ("Options", "Opções"), ("resolution_original_tip", "Resolução original"), ("resolution_fit_local_tip", "Adequar à resolução local"), ("resolution_custom_tip", "Customizar resolução"), diff --git a/src/lang/ro.rs b/src/lang/ro.rs index cc774057e..4423d9ddf 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Cineva activează modul privat, ieși din"), ("Unsupported", "Neacceptat"), ("Peer denied", "Dispozitiv pereche refuzat"), - ("Please install plugins", "Instalează pluginuri"), ("Peer exit", "Ieșire dispozitiv pereche"), ("Failed to turn off", "Dezactivare nereușită"), ("Turned off", "Închis"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Amprentă digitală"), ("Copy Fingerprint", "Copiază amprenta digitală"), ("no fingerprints", "Nicio amprentă digitală"), - ("Uninstall", "Dezinstalează"), ("Update", "Actualizează"), - ("Enable", "Activează"), - ("Disable", "Dezactivează"), - ("Options", "Opțiuni"), ("resolution_original_tip", "Rezoluție originală"), ("resolution_fit_local_tip", "Adaptează la rezoluția locală"), ("resolution_custom_tip", "Rezoluție personalizată"), diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 2ded4ebf5..6864383c7 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Кто-то включил режим конфиденциальности, выход"), ("Unsupported", "Не поддерживается"), ("Peer denied", "Отклонено удалённым узлом"), - ("Please install plugins", "Установите плагины"), ("Peer exit", "Отключено пользователем"), ("Failed to turn off", "Невозможно отключить"), ("Turned off", "Отключён"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Отпечаток"), ("Copy Fingerprint", "Копировать отпечаток"), ("no fingerprints", "отпечатки отсутствуют"), - ("Uninstall", "Удалить"), ("Update", "Обновить"), - ("Enable", "Включить"), - ("Disable", "Отключить"), - ("Options", "Настройки"), ("resolution_original_tip", "Исходное разрешение"), ("resolution_fit_local_tip", "Соответствие локальному разрешению"), ("resolution_custom_tip", "Произвольное разрешение"), diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 6bb1190c1..16ecaae87 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Calicunu at allutu sa modalidade de riservadesa, essida"), ("Unsupported", "Non suportadu"), ("Peer denied", "Atzessu negadu a su dispositivu remotu"), - ("Please install plugins", "Installa sos cumplementos"), ("Peer exit", "Essida dae su dispostivu remotu"), ("Failed to turn off", "Non faghet a istudare"), ("Turned off", "Istuda"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Firma digitale"), ("Copy Fingerprint", "Còpia firma digitale"), ("no fingerprints", "Peruna firma digitale"), - ("Uninstall", "Disinstalla"), ("Update", "Atualiza"), - ("Enable", "Abìlita"), - ("Disable", "Disabìlita"), - ("Options", "Optziones"), ("resolution_original_tip", "Risolutzione originale"), ("resolution_fit_local_tip", "Adata sa risolutzione locale"), ("resolution_custom_tip", "Risolutzione personalizada"), diff --git a/src/lang/sk.rs b/src/lang/sk.rs index 96eb423ef..83b5f269a 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Niekto zapne režim súkromia, ukončite ho"), ("Unsupported", "Nepodporované"), ("Peer denied", "Peer poprel"), - ("Please install plugins", "Nainštalujte si prosím pluginy"), ("Peer exit", "Peer exit"), ("Failed to turn off", "Nepodarilo sa vypnúť"), ("Turned off", "Vypnutý"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Odtlačok prsta"), ("Copy Fingerprint", "Kopírovať odtlačok prsta"), ("no fingerprints", "žiadne odtlačky prstov"), - ("Uninstall", "Odinštalovať"), ("Update", "Aktualizovať"), - ("Enable", "Povoliť"), - ("Disable", "Zakázať"), - ("Options", "Možnosti"), ("resolution_original_tip", "Pôvodné rozlíšenie"), ("resolution_fit_local_tip", "Prispôsobiť miestne rozlíšenie"), ("resolution_custom_tip", "Vlastné rozlíšenie"), diff --git a/src/lang/sl.rs b/src/lang/sl.rs old mode 100755 new mode 100644 index 82f177428..7d2e841da --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Vklopljen je zasebni način, izhod"), ("Unsupported", "Ni podprto"), ("Peer denied", "Odjemalec zavrnil"), - ("Please install plugins", "Namestite vključke"), ("Peer exit", "Odjemalec se je zaprl"), ("Failed to turn off", "Ni bilo mogoče izklopiti"), ("Turned off", "Izklopljeno"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Prstni odtis"), ("Copy Fingerprint", "Kopiraj prstni odtis"), ("no fingerprints", "ni prstnega odtisa"), - ("Uninstall", "Odstrani"), ("Update", "Posodobi"), - ("Enable", "Omogoči"), - ("Disable", "Onemogoči"), - ("Options", "Možnosti"), ("resolution_original_tip", "Izvirna ločljivost"), ("resolution_fit_local_tip", "Prilagodi lokalni ločljivosti"), ("resolution_custom_tip", "Ločljivost po meri"), diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 103a1bfe9..e77cf6c47 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Dikush ka ndezur menyrën e privatësisë , largohu"), ("Unsupported", "Nuk mbështetet"), ("Peer denied", "Peer mohohet"), - ("Please install plugins", "Ju lutemi instaloni shtojcat"), ("Peer exit", "Dalje peer"), ("Failed to turn off", "Dështoi të fiket"), ("Turned off", "I fikur"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Gjurma e gishtit"), ("Copy Fingerprint", "Kopjo gjurmën e gishtit"), ("no fingerprints", "Nuk ka gjurmë gishtash"), - ("Uninstall", "Çinstalo"), ("Update", "Përditëso"), - ("Enable", "Aktivizo"), - ("Disable", "Çaktivizo"), - ("Options", "Opsionet"), ("resolution_original_tip", "Rezolucioni origjinal"), ("resolution_fit_local_tip", "Përshtat me rezolucionin lokal"), ("resolution_custom_tip", "Rezolucion i personalizuar"), diff --git a/src/lang/sr.rs b/src/lang/sr.rs index c58f7b174..93bdc8dd8 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Neko je uključio mod privatnosti, izlaz."), ("Unsupported", "Nepodržano"), ("Peer denied", "Klijent zabranjen"), - ("Please install plugins", "Molimo instalirajte dodatke"), ("Peer exit", "Klijent izašao"), ("Failed to turn off", "Greška kod isključenja"), ("Turned off", "Isključeno"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Otisak"), ("Copy Fingerprint", "Kopiraj otisak"), ("no fingerprints", "Nema otisaka"), - ("Uninstall", "Deinstaliraj"), ("Update", "Ažuriraj"), - ("Enable", "Omogući"), - ("Disable", "Onemogući"), - ("Options", "Opcije"), ("resolution_original_tip", "Originalna rezolucija"), ("resolution_fit_local_tip", "Prilagodi lokalnoj rezoluciji"), ("resolution_custom_tip", "Prilagođena rezolucija"), diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 5075bd6d4..757034b91 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Någon sätter på säkerhetesläge, avsluta"), ("Unsupported", "Stöds inte"), ("Peer denied", "Klienten nekade"), - ("Please install plugins", "Var god installera plugins"), ("Peer exit", "Avsluta klient"), ("Failed to turn off", "Misslyckades med avstängning"), ("Turned off", "Avstängd"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingeravtryck"), ("Copy Fingerprint", "Kopiera fingeravtryck"), ("no fingerprints", "inga fingeravtryck"), - ("Uninstall", "Avinstallera"), ("Update", "Uppdatera"), - ("Enable", "Aktivera"), - ("Disable", "Inaktivera"), - ("Options", "Inställningar"), ("resolution_original_tip", "Ursprunglig upplösning"), ("resolution_fit_local_tip", "Anpassa till lokal upplösning"), ("resolution_custom_tip", "Anpassad upplösning"), diff --git a/src/lang/ta.rs b/src/lang/ta.rs index 37af3a97d..c5b6cb922 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "தனியுரிமை முறை இயக்கப்பட்டது, வெளியேறு"), ("Unsupported", "ஆதரவு இல்லை"), ("Peer denied", "இணையாளர் மறுத்தார்"), - ("Please install plugins", "இணைப்புகளை நிறுவுங்கள்"), ("Peer exit", "இணையாளர் வெளியேறினார்"), ("Failed to turn off", "அணைக்க முடியவில்லை"), ("Turned off", "அணைக்கப்பட்டது"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "கைரேகை"), ("Copy Fingerprint", "கைரேகை நகல்"), ("no fingerprints", "கைரேகைகள் இல்லை"), - ("Uninstall", "நிறுவல் நீக்கு"), ("Update", "புதுப்பி"), - ("Enable", "இயக்கு"), - ("Disable", "அணை"), - ("Options", "விருப்பங்கள்"), ("resolution_original_tip", "அசல் தெளிவுத்திறன்"), ("resolution_fit_local_tip", "உள்ளூர் பொருத்தம்"), ("resolution_custom_tip", "தனிப்பயன் தெளிவுத்திறன்"), diff --git a/src/lang/template.rs b/src/lang/template.rs index e31425369..b1809d900 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", ""), ("Unsupported", ""), ("Peer denied", ""), - ("Please install plugins", ""), ("Peer exit", ""), ("Failed to turn off", ""), ("Turned off", ""), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", ""), ("Copy Fingerprint", ""), ("no fingerprints", ""), - ("Uninstall", ""), ("Update", ""), - ("Enable", ""), - ("Disable", ""), - ("Options", ""), ("resolution_original_tip", ""), ("resolution_fit_local_tip", ""), ("resolution_custom_tip", ""), diff --git a/src/lang/th.rs b/src/lang/th.rs index 8e2c33ebb..f464e4bbd 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "มีใครบางคนเปิดใช้งานโหมดความเป็นส่วนตัว กำลังออก"), ("Unsupported", "ไม่รองรับ"), ("Peer denied", "ถูกปฏิเสธโดยอีกฝั่ง"), - ("Please install plugins", "กรุณาติดตั้งปลั๊กอิน"), ("Peer exit", "อีกฝั่งออก"), ("Failed to turn off", "การปิดล้มเหลว"), ("Turned off", "ปิด"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ลายนิ้วมือ"), ("Copy Fingerprint", "คัดลอกลายนิ้วมือ"), ("no fingerprints", "ไม่มีลายนิ้วมือ"), - ("Uninstall", "ถอนการติดตั้ง"), ("Update", "อัปเดต"), - ("Enable", "เปิดใช้งาน"), - ("Disable", "ปิดใช้งาน"), - ("Options", "ตัวเลือก"), ("resolution_original_tip", "ความละเอียดดั้งเดิม"), ("resolution_fit_local_tip", "ความละเอียดตามต้นทาง"), ("resolution_custom_tip", "ความละเอียดแบบกำหนดเอง"), diff --git a/src/lang/tr.rs b/src/lang/tr.rs index f548e39de..9b4fcbdc2 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Birisi gizlilik modunu açarsa, çık"), ("Unsupported", "desteklenmiyor"), ("Peer denied", "eş reddedildi"), - ("Please install plugins", "Lütfen eklentileri yükleyin"), ("Peer exit", "Eş çıkışı"), ("Failed to turn off", "Kapatılamadı"), ("Turned off", "Kapatıldı"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Parmak İzi"), ("Copy Fingerprint", "Parmak İzini Kopyala"), ("no fingerprints", "parmak izi yok"), - ("Uninstall", "Kaldır"), ("Update", "Güncelle"), - ("Enable", "Etkinleştir"), - ("Disable", "Devre Dışı Bırak"), - ("Options", "Seçenekler"), ("resolution_original_tip", "Orijinal çözünürlük"), ("resolution_fit_local_tip", "Yerel çözünürlüğe sığdır"), ("resolution_custom_tip", "Özel çözünürlük"), diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 6e22e4d79..88e78bd8f 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "有人開啟了隱私模式,退出"), ("Unsupported", "不支援"), ("Peer denied", "對方拒絕"), - ("Please install plugins", "請安裝外掛程式"), ("Peer exit", "對方退出"), ("Failed to turn off", "關閉失敗"), ("Turned off", "已關閉"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "指紋"), ("Copy Fingerprint", "複製指紋"), ("no fingerprints", "沒有指紋"), - ("Uninstall", "解除安裝"), ("Update", "更新"), - ("Enable", "啟用"), - ("Disable", "停用"), - ("Options", "選項"), ("resolution_original_tip", "原始解析度"), ("resolution_fit_local_tip", "調整成本機解析度"), ("resolution_custom_tip", "自訂解析度"), diff --git a/src/lang/uk.rs b/src/lang/uk.rs index f03bcb089..10fac1a96 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Хтось вмикає режим конфіденційності, вихід"), ("Unsupported", "Не підтримується"), ("Peer denied", "Відхилено віддаленим пристроєм"), - ("Please install plugins", "Будь ласка, встановіть плагіни"), ("Peer exit", "Вийти з віддаленого пристрою"), ("Failed to turn off", "Не вдалося вимкнути"), ("Turned off", "Вимкнений"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Відбитки пальців"), ("Copy Fingerprint", "Копіювати відбитки пальців"), ("no fingerprints", "немає відбитків пальців"), - ("Uninstall", "Видалити"), ("Update", "Оновити"), - ("Enable", "Увімкнути"), - ("Disable", "Вимкнути"), - ("Options", "Опції"), ("resolution_original_tip", "Початкова роздільна здатність"), ("resolution_fit_local_tip", "Припасувати поточну роздільну здатність"), ("resolution_custom_tip", "Користувацька роздільна здатність"), diff --git a/src/lang/vi.rs b/src/lang/vi.rs index 0b9421ba4..46faff12f 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Chế độ riêng tư đã được bật, thoát"), ("Unsupported", "Không hỗ trợ"), ("Peer denied", "Đối tác từ chối"), - ("Please install plugins", "Vui lòng cài đặt plugin"), ("Peer exit", "Đối tác đã thoát"), ("Failed to turn off", "Không thể tắt"), ("Turned off", "Đã tắt"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Dấu vân tay"), ("Copy Fingerprint", "Sao chép fingerprint"), ("no fingerprints", "không có fingerprint"), - ("Uninstall", "Gỡ cài đặt"), ("Update", "Cập nhật"), - ("Enable", "Bật"), - ("Disable", "Tắt"), - ("Options", "Tùy chọn"), ("resolution_original_tip", "Độ phân giải gốc"), ("resolution_fit_local_tip", "Vừa với máy cục bộ"), ("resolution_custom_tip", "Độ phân giải tùy chỉnh"), diff --git a/src/server/connection.rs b/src/server/connection.rs index 7dc41ecbb..fb7d1a2fe 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -1219,7 +1219,6 @@ impl Connection { } }, Err(err) => { - #[cfg(not(any(target_os = "android", target_os = "ios")))] if block_input_mode { let _ = crate::platform::block_input(true); } @@ -5011,7 +5010,7 @@ impl Connection { } } else { crate::common::make_privacy_mode_msg( - back_notification::PrivacyModeState::PrvOnFailedPlugin, + back_notification::PrivacyModeState::PrvOnFailed, impl_key, ) } From 8d52d48b2431f25d2cacdf5373d9653002215486 Mon Sep 17 00:00:00 2001 From: yzxcj797 <54314860+yzxcj797@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:18:21 +0800 Subject: [PATCH 26/72] docs: fix dead code of conduct links in ID/IT contributing guides (#15868) --- docs/CONTRIBUTING-ID.md | 2 +- docs/CONTRIBUTING-IT.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/CONTRIBUTING-ID.md b/docs/CONTRIBUTING-ID.md index cdff6c01f..b5ab29f46 100644 --- a/docs/CONTRIBUTING-ID.md +++ b/docs/CONTRIBUTING-ID.md @@ -24,7 +24,7 @@ Untuk instruksi Git yang lebih lanjut, cek disini [GitHub workflow 101](https:// ## Tindakan - + ## Komunikasi diff --git a/docs/CONTRIBUTING-IT.md b/docs/CONTRIBUTING-IT.md index a3a5fd2b6..f3ea9fbb7 100644 --- a/docs/CONTRIBUTING-IT.md +++ b/docs/CONTRIBUTING-IT.md @@ -30,7 +30,7 @@ Per istruzioni specifiche su git, vedi [Workflow GitHub - 101](https://github.co ## Condotta -https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT-IT.md +https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md ## Comunicazioni From 3871c47855eec2d3dab1f16dc7f2dbcecfbdcf1d Mon Sep 17 00:00:00 2001 From: yzxcj797 <54314860+yzxcj797@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:18:45 +0800 Subject: [PATCH 27/72] docs: remove stale flutter/web/js entry and fix dead localized build links (#15869) --- README.md | 1 - docs/README-AR.md | 1 - docs/README-CS.md | 1 - docs/README-DE.md | 1 - docs/README-ES.md | 1 - docs/README-FA.md | 1 - docs/README-GR.md | 1 - docs/README-HU.md | 3 +-- docs/README-IT.md | 1 - docs/README-JP.md | 1 - docs/README-KR.md | 1 - docs/README-NO.md | 1 - docs/README-PL.md | 1 - docs/README-PTBR.md | 1 - docs/README-RO.md | 1 - docs/README-RU.md | 3 +-- docs/README-TR.md | 1 - docs/README-UA.md | 1 - docs/README-VN.md | 1 - docs/README-ZH.md | 1 - 20 files changed, 2 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index b30a34cb2..1bb120c81 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,6 @@ Please ensure that you run these commands from the root of the RustDesk reposito - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for desktop and mobile -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript for Flutter web client ## Screenshots diff --git a/docs/README-AR.md b/docs/README-AR.md index 5aa09da88..6996ff7c3 100644 --- a/docs/README-AR.md +++ b/docs/README-AR.md @@ -160,7 +160,6 @@ RustDesk يرجى التأكد من أنك تنفذ هذه الأوامر من - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: أو المنقول عن بُعد (TCP hole punching) انتظر الاتصال المباشر [rustdesk-server](https://github.com/rustdesk/rustdesk-server) الإتصال ب - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: رمز خاص بكل منصة - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: رمز الهاتف المحمول -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**:Flutter لعميل الويب الخاص ب Javascript ## لقطات diff --git a/docs/README-CS.md b/docs/README-CS.md index b208414fe..2555bd8dd 100644 --- a/docs/README-CS.md +++ b/docs/README-CS.md @@ -144,7 +144,6 @@ Ujistěte se, že tyto příkazy spouštíte z kořenového adresáře RustDesk, - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: komunikace s [rustdesk-server](https://github.com/rustdesk/rustdesk-server), očekávání vzdálených příméhých („proděrováváním“ TCP) nebo předávaných (relay) spojení - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: zdrojové kódy, specifické pro jednotlivé platformy - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: zdrojové kódy pro použití s aplikačním rámcem (framework) Flutter pro mobilní platformy -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript pro Flutter webový klient ## Ukázky diff --git a/docs/README-DE.md b/docs/README-DE.md index ba8894411..f76e00fe5 100644 --- a/docs/README-DE.md +++ b/docs/README-DE.md @@ -168,7 +168,6 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Mit [rustdesk-server](https://github.com/rustdesk/rustdesk-server) kommunizieren, warten auf direkte (TCP hole punching) oder weitergeleitete Verbindung - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: Plattformspezifischer Code - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter-Code für Handys -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript für Flutter-Webclient ## Screenshots diff --git a/docs/README-ES.md b/docs/README-ES.md index da939bd7b..88cce46ad 100644 --- a/docs/README-ES.md +++ b/docs/README-ES.md @@ -163,7 +163,6 @@ Por favor, asegurate de que estás ejecutando estos comandos desde la raíz del - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunicación con [rustdesk-server](https://github.com/rustdesk/rustdesk-server), esperar la conexión remota directa ("TCP hole punching") o conexión indirecta ("relayed") - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter, código para moviles -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript para el cliente web Flutter > [!Precaución] > **Descargo de responsabilidad por uso indebido:**
diff --git a/docs/README-FA.md b/docs/README-FA.md index a0645e02b..a0bef5acb 100644 --- a/docs/README-FA.md +++ b/docs/README-FA.md @@ -146,7 +146,6 @@ target/release/rustdesk - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript for Flutter web client ## تصاویر محیط نرم‌افزار diff --git a/docs/README-GR.md b/docs/README-GR.md index 8b0276bf8..1346bedbb 100644 --- a/docs/README-GR.md +++ b/docs/README-GR.md @@ -158,7 +158,6 @@ target/release/rustdesk - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter web client ## Στιγμιότυπα diff --git a/docs/README-HU.md b/docs/README-HU.md index 82d1d5550..fc74d4bfe 100644 --- a/docs/README-HU.md +++ b/docs/README-HU.md @@ -48,7 +48,7 @@ A telefonos verziók Flutter-t hasznának. Később lehetséges hogy Sciterről - Futtasd a `cargo run` parancsot -## [Építés](https://rustdesk.com/docs/hu/dev/build/) +## [Építés](https://rustdesk.com/docs/en/dev/build/) ## Hogyan építs Linuxon @@ -150,7 +150,6 @@ Kérlek mindenképpen nézd meg hogy ezeket a parancsokat a root RustDesk mappá - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript for Flutter web client ## Képernyőképek diff --git a/docs/README-IT.md b/docs/README-IT.md index 0393ee6c7..ee5351b6c 100644 --- a/docs/README-IT.md +++ b/docs/README-IT.md @@ -162,7 +162,6 @@ Assicurati di eseguire questi comandi dalla radice del repository RustDesk, altr - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: comunica con [rustdesk-server](https://github.com/rustdesk/rustdesk-server), attende la connessione remota diretta (TCP hole punching) oppure indiretta (relayed) - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: codice specifico della piattaforma - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: codice Flutter per desktop e mobile -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript per client web Flutter > [!Attenzione] > **Dichiarazione di non responsabilità per uso improprio:**
diff --git a/docs/README-JP.md b/docs/README-JP.md index c9f75640b..6abeae5c0 100644 --- a/docs/README-JP.md +++ b/docs/README-JP.md @@ -166,7 +166,6 @@ target/release/rustdesk - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server)と通信し、リモートの直接接続(TCPホールパンチング)や中継接続を担う。 - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: プラットフォーム固有のコード - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: デスクトップとモバイル向けのFlutterコード -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutterウェブクライアント向けのJavaScript > [!注意] > **:不正使用に関する免責事項**
diff --git a/docs/README-KR.md b/docs/README-KR.md index d7d3cf43e..354cfe708 100644 --- a/docs/README-KR.md +++ b/docs/README-KR.md @@ -168,7 +168,6 @@ RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는 - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server)와 통신, 원격 다이렉트 (TCP 홀 펀칭) 또는 릴레이 연결 대기 - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: 플랫폼별 코드 - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 데스크톱 및 모바일용 Flutter 코드 -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: Flutter 웹 클라이언트용 JavaScript ## 스크린샷 diff --git a/docs/README-NO.md b/docs/README-NO.md index 1352e8aed..9aac6d943 100644 --- a/docs/README-NO.md +++ b/docs/README-NO.md @@ -163,7 +163,6 @@ Venligst pass på att du kjører disse kommandoene fra roten av RustDesk reposit - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Kommunikasjon med [rustdesk-server](https://github.com/rustdesk/rustdesk-server), vent på direkte fjernstyring (TCP hulling) eller vidresendt tilkobling - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform spesefik kode - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter kode for desktop og mobil -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter nettsted klient ## Skjermbilder diff --git a/docs/README-PL.md b/docs/README-PL.md index 437682a9c..4b48b0996 100644 --- a/docs/README-PL.md +++ b/docs/README-PL.md @@ -155,7 +155,6 @@ Upewnij się, że uruchamiasz te polecenia z katalogu głównego repozytorium Ru - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Komunikacja z [rustdesk-server](https://github.com/rustdesk/rustdesk-server), czekanie na bezpośrednie (odpytywanie TCP) lub przekazywane połączenie - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: kod specyficzny dla danej platformy - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: kod Flutter dla urządzeń mobilnych -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript dla Flutter - klient web ## Zrzuty ekranu diff --git a/docs/README-PTBR.md b/docs/README-PTBR.md index 2b4c1e6c2..bd16806ef 100644 --- a/docs/README-PTBR.md +++ b/docs/README-PTBR.md @@ -166,7 +166,6 @@ Certifique-se de executar esses comandos a partir da raiz do repositório do Rus - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunica-se com o [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguarda por conexão remota direta (perfuração de túnel TCP / hole punching) ou retransmitida. - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma. - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: código Flutter para desktop e dispositivos móveis. -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript para o cliente web do Flutter. ## Capturas de Tela diff --git a/docs/README-RO.md b/docs/README-RO.md index be7ecf164..d2b748e47 100644 --- a/docs/README-RO.md +++ b/docs/README-RO.md @@ -168,7 +168,6 @@ Asigură-te că rulezi aceste comenzi din rădăcina repository-ului RustDesk, a - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: comunică cu [rustdesk-server](https://github.com/rustdesk/rustdesk-server), așteaptă conexiune directă remote (TCP hole punching) sau prin relay - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: cod specific platformei - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: cod Flutter pentru desktop și mobil -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript pentru clientul Flutter web ## Capturi de ecran diff --git a/docs/README-RU.md b/docs/README-RU.md index 928faad07..c3c208066 100644 --- a/docs/README-RU.md +++ b/docs/README-RU.md @@ -59,7 +59,7 @@ RustDesk приветствует вклад каждого. Ознакомьт - Выполните команду `cargo run` -## [Сборка](https://rustdesk.com/docs/ru/dev/build/) +## [Сборка](https://rustdesk.com/docs/en/dev/build/) ## Как собрать на Linux @@ -170,7 +170,6 @@ target/release/rustdesk - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: связь с [сервером RustDesk](https://github.com/rustdesk/rustdesk-server), ожидает удаленного прямого (через TCP hole punching) или ретранслируемого соединения - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфичный для платформы код - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для ПК-версии и мобильных устройств -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript для Web-клиента Flutter ## Скриншоты diff --git a/docs/README-TR.md b/docs/README-TR.md index 99c961e8b..022335b94 100644 --- a/docs/README-TR.md +++ b/docs/README-TR.md @@ -166,7 +166,6 @@ Lütfen bu komutları RustDesk reposunun root klasöründe çalıştırdığın - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server) ile iletişime gir, remote direct(TCP delik açma) yada relay bağlantısı için bekle - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platforma özgü kod - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Masaüstü ve mobil için Flutter kodu -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: Flutter web istemcisi için JavaScript ## Ekran Görüntüleri diff --git a/docs/README-UA.md b/docs/README-UA.md index eb4c9edec..3da69acad 100644 --- a/docs/README-UA.md +++ b/docs/README-UA.md @@ -160,7 +160,6 @@ target/release/rustdesk - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: комунікація з [rustdesk-server](https://github.com/rustdesk/rustdesk-server), очікування віддаленого прямого (обхід TCP NAT) або ретрансльованого зʼєднання - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфічний для платформи код - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для мобільних пристроїв -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript для веб клієнта на Flutter ## Знімки екрана diff --git a/docs/README-VN.md b/docs/README-VN.md index 38cdc10fb..34cef261f 100644 --- a/docs/README-VN.md +++ b/docs/README-VN.md @@ -148,7 +148,6 @@ Hãy đảm bảo rằng bạn đang chạy các lệnh này từ gốc của th - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: giao tiếp với [rustdesk-server](https://github.com/rustdesk/rustdesk-server), đợi kết nối trực tiếp (TCP hole punching) hoặc kết nối được chuyển tiếp. - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: mã nguồn riêng cho mỗi nền tảng - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Mã Flutter dành máy tính và điện thoại -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Mã JavaScript dành cho giao diện trên web bằng Flutter ## Snapshot diff --git a/docs/README-ZH.md b/docs/README-ZH.md index 9328e52e9..dc73d85a5 100644 --- a/docs/README-ZH.md +++ b/docs/README-ZH.md @@ -220,7 +220,6 @@ target/release/rustdesk - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: 与[rustdesk-server](https://github.com/rustdesk/rustdesk-server)保持UDP通讯, 等待远程连接(通过打洞直连或者中继) - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: 平台服务相关代码 - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 适用于桌面和移动设备的 Flutter 代码 -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter Web版本中的Javascript代码 ## 截图 From edd0e5fbd44729e6371ead9bcfabdd541317b8af Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 17 Aug 2026 09:30:19 +0800 Subject: [PATCH 28/72] fix(CI): rust 1.75, linux sciter (#15874) Signed-off-by: fufesou --- .github/workflows/flutter-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 4f1dbb1c9..81a448a90 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -2203,7 +2203,7 @@ jobs: mkdir -p ~/.cargo/ echo """ [source.crates-io] - registry = 'https://github.com/rust-lang/crates.io-index' + registry = 'sparse+https://index.crates.io/' """ > ~/.cargo/config cat ~/.cargo/config # install dependencies from vcpkg From 5a78be03e3c2edb676b7e5b0bcab6a5c9a47d26d Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:30:18 +0800 Subject: [PATCH 29/72] feat(rdp): title the mstsc window after the peer instead of "localhost" (#15781) * feat(rdp): title the mstsc window after the peer instead of "localhost" The RDP tunnel launched `mstsc /v:localhost:`, so with several sessions open every window is titled "localhost" and servers cannot be told apart. mstsc titles the session window after the launched .rdp file's base name, so write a temp .rdp file (containing only the tunnel address) named after the peer alias, cached hostname, or id, and launch that instead. Falls back to the old /v: form when no usable name remains after filename sanitization or the file cannot be written. Credential handling is unchanged: cmdkey targets "localhost", which is still the host mstsc resolves credentials against. Fixes rustdesk/rustdesk#15775 (discussion) Co-Authored-By: Claude Fable 5 * fix(rdp): set mstsc title without temporary files Keep launching mstsc with /v so Default.rdp settings are preserved and unsigned RDP file warnings and policy restrictions are avoided. Track the launched mstsc process and reapply the peer name when the window title is reset during connection or reconnection. Signed-off-by: 21pages * docs(rdp): clarify mstsc title limitation Signed-off-by: 21pages * feat(rdp): show peer identity with hostname in mstsc title Signed-off-by: 21pages --------- Signed-off-by: 21pages Co-authored-by: Claude Fable 5 Co-authored-by: 21pages --- src/platform/windows.rs | 85 +++++++++++++++++++++++++++++++++++++++++ src/port_forward.rs | 37 +++++++++++++++--- 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 5253895dd..e32313987 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -2642,6 +2642,91 @@ pub fn wide_string(s: &str) -> Vec { .collect() } +// This only changes mstsc's top-level window title. The full-screen connection +// bar is rendered separately and cannot be customized when mstsc.exe is +// launched as an independent process. +pub fn set_rdp_window_title(mut child: std::process::Child, name: String) { + let name: String = name.chars().filter(|c| !c.is_control()).take(120).collect(); + if name.is_empty() { + return; + } + let process_id = child.id(); + // mstsc owns the title and can restore "localhost" while connecting or + // reconnecting. Follow only the process we launched and reapply the peer + // name until it exits, so concurrent RDP sessions cannot rename each other. + if let Err(err) = std::thread::Builder::new() + .name("rdp-window-title".to_owned()) + .spawn(move || { + let mut warned = false; + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Err(err) => { + log::warn!("Failed to query mstsc process: {}", err); + break; + } + Ok(None) => match set_process_rdp_window_title(process_id, &name) { + Ok(()) => warned = false, + Err(err) if !warned => { + log::warn!("Failed to set RDP window title: {}", err); + warned = true; + } + Err(_) => {} + }, + } + std::thread::sleep(Duration::from_millis(500)); + } + }) + { + log::warn!("Failed to start RDP window title thread: {}", err); + } +} + +fn set_process_rdp_window_title(process_id: DWORD, name: &str) -> io::Result<()> { + struct Context { + process_id: DWORD, + title: Vec, + error: Option, + } + + unsafe extern "system" fn enum_window(hwnd: HWND, lparam: LPARAM) -> BOOL { + let context = &mut *(lparam as *mut Context); + let mut window_process_id = 0; + GetWindowThreadProcessId(hwnd, &mut window_process_id); + if window_process_id != context.process_id || IsWindowVisible(hwnd) == FALSE { + return TRUE; + } + let len = GetWindowTextLengthW(hwnd); + if len <= 0 { + return TRUE; + } + let mut title = vec![0u16; len as usize + 1]; + let len = GetWindowTextW(hwnd, title.as_mut_ptr(), title.len() as _); + if len > 0 && String::from_utf16_lossy(&title[..len as usize]).contains("localhost") { + if SetWindowTextW(hwnd, context.title.as_ptr()) == FALSE { + context.error = Some(io::Error::last_os_error()); + return FALSE; + } + } + TRUE + } + + let mut context = Context { + process_id, + title: wide_string(name), + error: None, + }; + let enumerated = + unsafe { EnumWindows(Some(enum_window), &mut context as *mut Context as LPARAM) }; + if let Some(err) = context.error { + return Err(err); + } + if enumerated == FALSE { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + /// send message to currently shown window pub fn send_message_to_hnwd( class_name: &str, diff --git a/src/port_forward.rs b/src/port_forward.rs index 7a3f8715c..392ed3c67 100644 --- a/src/port_forward.rs +++ b/src/port_forward.rs @@ -15,7 +15,7 @@ use hbb_common::{ ResultType, Stream, }; -fn run_rdp(port: u16) { +fn run_rdp(port: u16, name: &str) { std::process::Command::new("cmdkey") .arg("/delete:localhost") .output() @@ -35,10 +35,37 @@ fn run_rdp(port: u16) { .output() .ok(); } - std::process::Command::new("mstsc") + // Keep using /v instead of a generated .rdp file: mstsc then preserves the + // user's Default.rdp settings and avoids unsigned-file warnings or policies. + match std::process::Command::new("mstsc") .arg(format!("/v:localhost:{}", port)) .spawn() - .ok(); + { + Ok(child) => { + #[cfg(windows)] + crate::platform::set_rdp_window_title(child, name.to_owned()); + #[cfg(not(windows))] + let _ = (child, name); + } + Err(err) => log::warn!("Failed to launch mstsc: {}", err), + } +} + +// Show the peer identity with its hostname, using the ID when no alias exists. +fn rdp_display_name(lc: &Arc>, id: &str) -> String { + let lc = lc.read().unwrap(); + let alias = lc + .options + .get("alias") + .map(|s| s.trim()) + .unwrap_or_default(); + let hostname = lc.info.hostname.trim(); + let identity = if !alias.is_empty() { alias } else { id }; + if hostname.is_empty() || hostname == identity { + identity.to_owned() + } else { + format!("{} ({})", identity, hostname) + } } pub async fn listen( @@ -58,7 +85,7 @@ pub async fn listen( log::info!("listening on port {:?}", addr); let is_rdp = port == 0; if is_rdp { - run_rdp(addr.port()); + run_rdp(addr.port(), &rdp_display_name(&lc, &id)); } let mut ui_receiver = ui_receiver; loop { @@ -96,7 +123,7 @@ pub async fn listen( } Some(Data::NewRDP) => { println!("receive run_rdp from ui_receiver"); - run_rdp(addr.port()); + run_rdp(addr.port(), &rdp_display_name(&lc, &id)); } _ => {} } From 8ffe3117a53716f1df431879b69132c71f969cc3 Mon Sep 17 00:00:00 2001 From: Krik JIN <41183997+jinhk7@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:04:58 +0800 Subject: [PATCH 30/72] feat(flutter): add mobile canvas lock (#15877) * feat: add mobile canvas lock * Update flutter/lib/models/model.dart Remove redundant canvas-lock comment Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * remove redundant logic Signed-off-by: fufesou --------- Signed-off-by: fufesou Co-authored-by: Krik Jin Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: fufesou --- flutter/lib/common/widgets/remote_input.dart | 3 +++ flutter/lib/mobile/pages/remote_page.dart | 8 ++++++++ flutter/lib/models/model.dart | 9 +++++++++ src/lang/ar.rs | 1 + src/lang/be.rs | 1 + src/lang/bg.rs | 1 + src/lang/ca.rs | 1 + src/lang/cn.rs | 1 + src/lang/cs.rs | 1 + src/lang/da.rs | 1 + src/lang/de.rs | 1 + src/lang/el.rs | 1 + src/lang/eo.rs | 1 + src/lang/es.rs | 1 + src/lang/et.rs | 1 + src/lang/eu.rs | 1 + src/lang/fa.rs | 1 + src/lang/fi.rs | 1 + src/lang/fr.rs | 1 + src/lang/ge.rs | 1 + src/lang/gu.rs | 1 + src/lang/he.rs | 1 + src/lang/hi.rs | 1 + src/lang/hr.rs | 1 + src/lang/hu.rs | 1 + src/lang/id.rs | 1 + src/lang/it.rs | 1 + src/lang/ja.rs | 1 + src/lang/ko.rs | 1 + src/lang/kz.rs | 1 + src/lang/lt.rs | 1 + src/lang/lv.rs | 1 + src/lang/ml.rs | 1 + src/lang/nb.rs | 1 + src/lang/nl.rs | 1 + src/lang/pl.rs | 1 + src/lang/pt_PT.rs | 1 + src/lang/ptbr.rs | 1 + src/lang/ro.rs | 1 + src/lang/ru.rs | 1 + src/lang/sc.rs | 1 + src/lang/sk.rs | 1 + src/lang/sl.rs | 1 + src/lang/sq.rs | 1 + src/lang/sr.rs | 1 + src/lang/sv.rs | 1 + src/lang/ta.rs | 1 + src/lang/template.rs | 1 + src/lang/th.rs | 1 + src/lang/tr.rs | 1 + src/lang/tw.rs | 1 + src/lang/uk.rs | 1 + src/lang/vi.rs | 1 + 53 files changed, 70 insertions(+) diff --git a/flutter/lib/common/widgets/remote_input.dart b/flutter/lib/common/widgets/remote_input.dart index 5871033db..1e2daac5d 100644 --- a/flutter/lib/common/widgets/remote_input.dart +++ b/flutter/lib/common/widgets/remote_input.dart @@ -115,6 +115,7 @@ class _RawTouchGestureDetectorRegionState InputModel get inputModel => widget.inputModel; bool get handleTouch => (isDesktop || isWebDesktop) || ffiModel.touchMode; SessionID get sessionId => ffi.sessionId; + bool get canvasLocked => isMobile && ffi.canvasModel.locked; @override Widget build(BuildContext context) { @@ -471,6 +472,8 @@ class _RawTouchGestureDetectorRegionState return; } + if (canvasLocked) return; + if ((isDesktop || isWebDesktop)) { final scale = ((d.scale - _scale) * 1000).toInt(); _scale = d.scale; diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 8395f4540..f42d08a67 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -1276,6 +1276,14 @@ void showOptions( List cursorToggles = await toolbarCursor(context, id, gFFI); List displayToggles = await toolbarDisplayToggle(context, id, gFFI); + if (isMobile) { + displayToggles.insert( + 0, + TToggleMenu( + child: Text(translate('Lock canvas')), + value: gFFI.canvasModel.locked, + onChanged: (value) => gFFI.canvasModel.setLocked(value == true))); + } List privacyModeList = []; if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) || diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 68ec58cc3..7bab906bc 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -2213,6 +2213,7 @@ class CanvasModel with ChangeNotifier { double _y = 0; // image scale double _scale = 1.0; + bool _locked = false; double _devicePixelRatio = 1.0; Size _size = Size.zero; // the tabbar over the image @@ -2261,12 +2262,19 @@ class CanvasModel with ChangeNotifier { double get x => _x; double get y => _y; double get scale => _scale; + bool get locked => _locked; double get devicePixelRatio => _devicePixelRatio; Size get size => _size; ScrollStyle get scrollStyle => _scrollStyle; ViewStyle get viewStyle => _lastViewStyle; RxBool get imageOverflow => _imageOverflow; + void setLocked(bool value) { + if (_locked == value) return; + _locked = value; + notifyListeners(); + } + _resetScroll() => setScrollPercent(0.0, 0.0); void setScrollPercent(double x, double y) { @@ -2727,6 +2735,7 @@ class CanvasModel with ChangeNotifier { _x = 0; _y = 0; _scale = 1.0; + _locked = false; _lastViewStyle = ViewStyle.defaultViewStyle(); _timerMobileFocusCanvasCursor?.cancel(); _timerMobileRestoreCanvasOffset?.cancel(); diff --git a/src/lang/ar.rs b/src/lang/ar.rs index f66beca8f..a396182ba 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "يتم دعم صيغة CIDR، مثال: 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index 411ea6ec5..607d1f195 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Падтрымліваецца натацыя CIDR, напрыклад: 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index b56334f6d..29b233a9a 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Поддържа се CIDR нотация, например: 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 1412a2b76..b7d25420f 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "S'admet la notació CIDR, per exemple 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index e685554b4..b8b61fa43 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "支持 CIDR 写法,例如 192.168.1.0/24"), ("Continue", "继续"), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", "锁定画布"), ].iter().cloned().collect(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index 7bf85ec49..05d347dc9 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Je podporován zápis CIDR, například 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index d8d9caaf6..9d03a7ff1 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "CIDR-notation understøttes, f.eks. 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index 44da8446b..53abc3ec3 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Die CIDR-Notation wird unterstützt, z. B. 192.168.1.0/24"), ("Continue", "Weiter"), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index d6f96fa3c..411eab380 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Υποστηρίζεται η σημειογραφία CIDR, π.χ. 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/eo.rs b/src/lang/eo.rs index f7048783c..3fcbfc2e2 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "La notacio CIDR estas subtenata, ekzemple 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index 3285a71e0..0f1904805 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Se admite la notación CIDR, por ejemplo 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index a97ad97ff..69cf74483 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Toetatud on CIDR-tähistus, näiteks 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index ef534828f..e19913b36 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "CIDR notazioa onartzen da, adibidez 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index 7b01a1a7b..aa3e02574 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "نماد CIDR پشتیبانی می شود، برای مثال 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fi.rs b/src/lang/fi.rs index b4bd3cb5b..c6532f65b 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "CIDR-merkintä on tuettu, esimerkiksi 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 9a4acf6f3..8d592b3ae 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "La notation CIDR est prise en charge, par exemple 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs index f2d807c4b..3061bfeb3 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "მხარდაჭერილია CIDR ჩანაწერი, მაგალითად 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 28c4b7a89..61d72559e 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "CIDR નોટેશન સપોર્ટેડ છે, ઉदાહરણ તરીકે 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/he.rs b/src/lang/he.rs index e11826d01..73e2dcef9 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "יש תמיכה בסימון CIDR, לדוגמה 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hi.rs b/src/lang/hi.rs index 0b1da4efe..d3e8dc399 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "CIDR नोटेशन समर्थित है, उदाहरण के लिए 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hr.rs b/src/lang/hr.rs index d74ab784f..72cb3c466 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Podržan je CIDR zapis, primjerice 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 3244269b4..8bf6499ed 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "A CIDR jelölés támogatott, például 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index 594ea50f6..d1cca6374 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Notasi CIDR didukung, misalnya 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index 9747a35c3..c0567e4de 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "È supportata la notazione CIDR, ad esempio 192.168.1.0/24"), ("Continue", "Continua"), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index 9ff8d2dc4..556d19adb 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "CIDR 表記に対応しています。例: 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index a55eb6695..9ea26c293 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "CIDR 표기를 지원합니다. 예: 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index 998b74172..5532f8368 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "CIDR жазбасына қолдау көрсетіледі, мысалы 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 5611cc192..24d56ee90 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Palaikomas CIDR žymėjimas, pavyzdžiui 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index 5ac325cb6..f0fa78770 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Tiek atbalstīts CIDR pieraksts, piemēram 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ml.rs b/src/lang/ml.rs index c781d288a..5e69ac96a 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "CIDR നൊട്ടേഷൻ പിന്തുണയ്ക്കുന്നു, ഉദാഹരണത്തിന് 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/nb.rs b/src/lang/nb.rs index ef26b87d6..72c86ce0c 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "CIDR-notasjon støttes, for eksempel 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index e94d66c94..eafd4033d 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "CIDR-notatie wordt ondersteund, bijv. 192.168.1.0/24"), ("Continue", "Doorgaan"), ("Browser didn't open? Use the url below to sign in.", "Is de browser niet geopend? Gebruik onderstaande URL om in te loggen."), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index ea5bd47e5..bc1a231fa 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Obsługiwana jest notacja CIDR, na przykład 192.168.1.0/24"), ("Continue", "Kontynuuj"), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index e06b46559..debd5adb3 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "A notação CIDR é suportada, por exemplo 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 69adca61e..629839c60 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "A notação CIDR é suportada, por exemplo 192.168.1.0/24"), ("Continue", "Continuar"), ("Browser didn't open? Use the url below to sign in.", "O navegador não foi aberto? Use a URL abaixo para fazer login."), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index 4423d9ddf..51522b093 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Notația CIDR este acceptată, de exemplu 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 6864383c7..df71fbb51 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Поддерживается нотация CIDR, например 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 16ecaae87..bfb06a332 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Sa notatzione CIDR est suportada, pro esempru 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sk.rs b/src/lang/sk.rs index 83b5f269a..b3bac432c 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Je podporovaný zápis CIDR, napríklad 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs index 7d2e841da..be4e7b3d2 100644 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Podprt je zapis CIDR, na primer 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index e77cf6c47..9b61e19ff 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Mbështetet shënimi CIDR, për shembull 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index 93bdc8dd8..f3a4521b8 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Podržan je CIDR zapis, na primer 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 757034b91..236624134 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "CIDR-notation stöds, till exempel 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs index c5b6cb922..779466e60 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "CIDR குறியீடு ஆதரிக்கப்படுகிறது, எடுத்துக்காட்டாக 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/template.rs b/src/lang/template.rs index b1809d900..c529dfada 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", ""), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index f464e4bbd..dc8d2e863 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "รองรับรูปแบบ CIDR เช่น 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 9b4fcbdc2..6394e1c45 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "CIDR gösterimi desteklenir, örneğin 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 88e78bd8f..eb05f9e7c 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "支援 CIDR 寫法,例如 192.168.1.0/24"), ("Continue", "繼續"), ("Browser didn't open? Use the url below to sign in.", "瀏覽器未開啟?請使用下方網址登入。"), + ("Lock canvas", "鎖定畫布"), ].iter().cloned().collect(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 10fac1a96..2f8675456 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Підтримується нотація CIDR, наприклад 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } diff --git a/src/lang/vi.rs b/src/lang/vi.rs index 46faff12f..b5e8bd573 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -767,5 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "Hỗ trợ ký hiệu CIDR, ví dụ 192.168.1.0/24"), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), ].iter().cloned().collect(); } From 14a4b197adc8b80fc954ad11fde50d443305a176 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Mon, 17 Aug 2026 17:22:57 +0800 Subject: [PATCH 31/72] translations --- src/lang/ar.rs | 6 +++--- src/lang/be.rs | 6 +++--- src/lang/bg.rs | 6 +++--- src/lang/ca.rs | 6 +++--- src/lang/cn.rs | 2 +- src/lang/cs.rs | 6 +++--- src/lang/da.rs | 6 +++--- src/lang/de.rs | 4 ++-- src/lang/el.rs | 6 +++--- src/lang/eo.rs | 6 +++--- src/lang/es.rs | 6 +++--- src/lang/et.rs | 6 +++--- src/lang/eu.rs | 6 +++--- src/lang/fa.rs | 6 +++--- src/lang/fi.rs | 6 +++--- src/lang/fr.rs | 6 +++--- src/lang/ge.rs | 6 +++--- src/lang/gu.rs | 6 +++--- src/lang/he.rs | 6 +++--- src/lang/hi.rs | 6 +++--- src/lang/hr.rs | 6 +++--- src/lang/hu.rs | 6 +++--- src/lang/id.rs | 6 +++--- src/lang/it.rs | 4 ++-- src/lang/ja.rs | 6 +++--- src/lang/ko.rs | 6 +++--- src/lang/kz.rs | 6 +++--- src/lang/lt.rs | 6 +++--- src/lang/lv.rs | 6 +++--- src/lang/ml.rs | 6 +++--- src/lang/nb.rs | 6 +++--- src/lang/nl.rs | 2 +- src/lang/pl.rs | 4 ++-- src/lang/pt_PT.rs | 6 +++--- src/lang/ptbr.rs | 2 +- src/lang/ro.rs | 6 +++--- src/lang/ru.rs | 6 +++--- src/lang/sc.rs | 6 +++--- src/lang/sk.rs | 6 +++--- src/lang/sl.rs | 6 +++--- src/lang/sq.rs | 6 +++--- src/lang/sr.rs | 6 +++--- src/lang/sv.rs | 6 +++--- src/lang/ta.rs | 6 +++--- src/lang/th.rs | 6 +++--- src/lang/tr.rs | 6 +++--- src/lang/uk.rs | 6 +++--- src/lang/vi.rs | 6 +++--- 48 files changed, 135 insertions(+), 135 deletions(-) diff --git a/src/lang/ar.rs b/src/lang/ar.rs index a396182ba..61044da3e 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "تم حظر عنوان IP الخاص بك من قبل الطرف الآخر"), ("id_whitelist_caveat_tip", "يتم الإبلاغ عن المعرف من قبل العميل المتصل. القائمة البيضاء تقلل من التعرض ولا تغني عن كلمة المرور أو 2FA"), ("whitelist_cidr_tip", "يتم دعم صيغة CIDR، مثال: 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "متابعة"), + ("Browser didn't open? Use the url below to sign in.", "لم يفتح المتصفح؟ استخدم الرابط أدناه لتسجيل الدخول."), + ("Lock canvas", "قفل اللوحة"), ].iter().cloned().collect(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index 607d1f195..1159dac84 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Ваш IP-адрас заблакаваны аддаленай прыладай"), ("id_whitelist_caveat_tip", "ID паведамляецца кліентам, які падключаецца. Белы спіс памяншае паверхню атакі і не замяняе пароль або 2FA"), ("whitelist_cidr_tip", "Падтрымліваецца натацыя CIDR, напрыклад: 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Працягнуць"), + ("Browser didn't open? Use the url below to sign in.", "Браўзер не адкрыўся? Скарыстайцеся спасылкай ніжэй, каб увайсці."), + ("Lock canvas", "Заблакіраваць палатно"), ].iter().cloned().collect(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 29b233a9a..568ec085e 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Вашият IP адрес е блокиран от отсрещната страна"), ("id_whitelist_caveat_tip", "ID се съобщава от свързващия се клиент. Белият списък намалява изложеността и не замества паролата или 2FA"), ("whitelist_cidr_tip", "Поддържа се CIDR нотация, например: 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Продължи"), + ("Browser didn't open? Use the url below to sign in.", "Браузърът не се отвори? Използвайте URL адреса по-долу, за да се впишете."), + ("Lock canvas", "Заключване на платното"), ].iter().cloned().collect(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index b7d25420f..3bc9c6375 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "La vostra IP està bloquejada per l'altre extrem"), ("id_whitelist_caveat_tip", "L'ID és informat pel client que es connecta. Aquesta llista blanca redueix l'exposició i no substitueix la contrasenya ni la 2FA"), ("whitelist_cidr_tip", "S'admet la notació CIDR, per exemple 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Continua"), + ("Browser didn't open? Use the url below to sign in.", "No s'ha obert el navegador? Utilitzeu l'URL de sota per iniciar la sessió."), + ("Lock canvas", "Bloca el llenç"), ].iter().cloned().collect(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index b8b61fa43..b64824731 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -766,7 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("id_whitelist_caveat_tip", "ID 由对端客户端上报,白名单用于减少暴露面,不能替代密码或 2FA"), ("whitelist_cidr_tip", "支持 CIDR 写法,例如 192.168.1.0/24"), ("Continue", "继续"), - ("Browser didn't open? Use the url below to sign in.", ""), + ("Browser didn't open? Use the url below to sign in.", "浏览器未打开?请使用下方网址登录。"), ("Lock canvas", "锁定画布"), ].iter().cloned().collect(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index 05d347dc9..fd32948ad 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Vaše IP adresa je protistranou blokována"), ("id_whitelist_caveat_tip", "ID je hlášeno připojujícím se klientem. Tento seznam snižuje vystavení a nenahrazuje heslo ani 2FA"), ("whitelist_cidr_tip", "Je podporován zápis CIDR, například 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Pokračovat"), + ("Browser didn't open? Use the url below to sign in.", "Neotevřel se prohlížeč? Pro přihlášení použijte URL níže."), + ("Lock canvas", "Zamknout zobrazení"), ].iter().cloned().collect(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index 9d03a7ff1..3edbb9d2d 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Din IP-adresse er blokeret af modparten"), ("id_whitelist_caveat_tip", "ID'et rapporteres af den klient, der opretter forbindelse. Whitelisten reducerer eksponeringen og erstatter ikke adgangskode eller 2FA"), ("whitelist_cidr_tip", "CIDR-notation understøttes, f.eks. 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Fortsæt"), + ("Browser didn't open? Use the url below to sign in.", "Åbnede browseren ikke? Brug URL'en nedenfor til at logge ind."), + ("Lock canvas", "Lås lærred"), ].iter().cloned().collect(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index 53abc3ec3..dcddb94fa 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -766,7 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("id_whitelist_caveat_tip", "Die ID wird vom verbindenden Client gemeldet. Die Whitelist verringert die Angriffsfläche und ersetzt weder Passwort noch 2FA."), ("whitelist_cidr_tip", "Die CIDR-Notation wird unterstützt, z. B. 192.168.1.0/24"), ("Continue", "Weiter"), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Browser didn't open? Use the url below to sign in.", "Hat sich der Browser nicht geöffnet? Melden Sie sich über die untenstehende URL an."), + ("Lock canvas", "Sichtfeld sperren"), ].iter().cloned().collect(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index 411eab380..28088a6bd 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Η διεύθυνση IP σας έχει αποκλειστεί από τον απομακρυσμένο υπολογιστή"), ("id_whitelist_caveat_tip", "Το ID αναφέρεται από τον πελάτη που συνδέεται. Η λίστα επιτρεπόμενων μειώνει την έκθεση και δεν αντικαθιστά τον κωδικό πρόσβασης ή το 2FA"), ("whitelist_cidr_tip", "Υποστηρίζεται η σημειογραφία CIDR, π.χ. 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Συνέχεια"), + ("Browser didn't open? Use the url below to sign in.", "Δεν άνοιξε το πρόγραμμα περιήγησης; Χρησιμοποιήστε τον παρακάτω σύνδεσμο για να συνδεθείτε."), + ("Lock canvas", "Κλείδωμα καμβά"), ].iter().cloned().collect(); } diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 3fcbfc2e2..8ff0c573e 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Via IP estas blokita de la alia flanko"), ("id_whitelist_caveat_tip", "La ID estas raportata de la konektiĝanta kliento. La blanka listo malpliigas la eksponiĝon kaj ne anstataŭas la pasvorton aŭ 2FA"), ("whitelist_cidr_tip", "La notacio CIDR estas subtenata, ekzemple 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Daŭrigi"), + ("Browser didn't open? Use the url below to sign in.", "Ĉu la retumilo ne malfermiĝis? Uzu la suban ligilon por ensaluti."), + ("Lock canvas", "Ŝlosi kanvason"), ].iter().cloned().collect(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index 0f1904805..43eefdbdb 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Tu IP está bloqueada por el dispositivo remoto"), ("id_whitelist_caveat_tip", "El ID lo comunica el cliente que se conecta. Esta lista blanca reduce la exposición y no sustituye a la contraseña ni al 2FA"), ("whitelist_cidr_tip", "Se admite la notación CIDR, por ejemplo 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Continuar"), + ("Browser didn't open? Use the url below to sign in.", "¿No se abrió el navegador? Usa la URL de abajo para iniciar sesión."), + ("Lock canvas", "Bloquear lienzo"), ].iter().cloned().collect(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index 69cf74483..a4bbf543c 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Teine pool on sinu IP-aadressi blokeerinud"), ("id_whitelist_caveat_tip", "ID edastab ühenduv klient. Lubamisloend vähendab eksponeeritust ega asenda parooli või 2FA-d"), ("whitelist_cidr_tip", "Toetatud on CIDR-tähistus, näiteks 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Jätka"), + ("Browser didn't open? Use the url below to sign in.", "Brauser ei avanenud? Sisselogimiseks kasuta allolevat URL-i."), + ("Lock canvas", "Lukusta lõuend"), ].iter().cloned().collect(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index e19913b36..338c3dfbe 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Beste aldeak zure IP helbidea blokeatu du"), ("id_whitelist_caveat_tip", "IDa konektatzen den bezeroak jakinarazten du. Zerrenda honek esposizioa murrizten du eta ez du pasahitza edo 2FA ordezkatzen"), ("whitelist_cidr_tip", "CIDR notazioa onartzen da, adibidez 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Jarraitu"), + ("Browser didn't open? Use the url below to sign in.", "Nabigatzailea ez da ireki? Erabili beheko URLa saioa hasteko."), + ("Lock canvas", "Blokeatu oihala"), ].iter().cloned().collect(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index aa3e02574..f905d4b76 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "نشانی IP شما توسط طرف مقابل مسدود شده است"), ("id_whitelist_caveat_tip", "شناسه توسط کلاینت متصل شونده گزارش می شود. لیست مجاز سطح در معرض بودن را کاهش می دهد و جایگزین رمز عبور یا 2FA نیست"), ("whitelist_cidr_tip", "نماد CIDR پشتیبانی می شود، برای مثال 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "ادامه"), + ("Browser didn't open? Use the url below to sign in.", "مرورگر باز نشد؟ برای ورود از نشانی زیر استفاده کنید."), + ("Lock canvas", "قفل کردن صفحه"), ].iter().cloned().collect(); } diff --git a/src/lang/fi.rs b/src/lang/fi.rs index c6532f65b..c9b6442e3 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Vastapuoli on estänyt IP-osoitteesi"), ("id_whitelist_caveat_tip", "ID on yhdistävän asiakkaan ilmoittama. Sallintalista pienentää altistusta eikä korvaa salasanaa tai 2FA:ta"), ("whitelist_cidr_tip", "CIDR-merkintä on tuettu, esimerkiksi 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Jatka"), + ("Browser didn't open? Use the url below to sign in.", "Eikö selain avautunut? Kirjaudu sisään alla olevan osoitteen kautta."), + ("Lock canvas", "Lukitse näkymä"), ].iter().cloned().collect(); } diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 8d592b3ae..cb78c5ff5 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Votre adresse IP est bloquée par l’appareil distant"), ("id_whitelist_caveat_tip", "L’ID est déclaré par le client qui se connecte. Cette liste blanche réduit l’exposition et ne remplace ni le mot de passe ni la 2FA"), ("whitelist_cidr_tip", "La notation CIDR est prise en charge, par exemple 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Continuer"), + ("Browser didn't open? Use the url below to sign in.", "Le navigateur ne s’est pas ouvert ? Utilisez l’URL ci-dessous pour vous connecter."), + ("Lock canvas", "Verrouiller la vue"), ].iter().cloned().collect(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs index 3061bfeb3..d1c76c69f 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "თქვენი IP მისამართი დაბლოკილია მეორე მხარის მიერ"), ("id_whitelist_caveat_tip", "ID-ს აცხადებს დამაკავშირებელი კლიენტი. თეთრი სია ამცირებს ექსპოზიციას და ვერ ჩაანაცვლებს პაროლს ან 2FA-ს"), ("whitelist_cidr_tip", "მხარდაჭერილია CIDR ჩანაწერი, მაგალითად 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "გაგრძელება"), + ("Browser didn't open? Use the url below to sign in.", "ბრაუზერი არ გაიხსნა? შესასვლელად გამოიყენეთ ქვემოთ მოცემული ბმული."), + ("Lock canvas", "ტილოს დაბლოკვა"), ].iter().cloned().collect(); } diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 61d72559e..8efa89e31 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "તમારું IP સામેના પક્ષ દ્વારા બ્લોક કરવામાં આવ્યું છે"), ("id_whitelist_caveat_tip", "ID કનેક્ટ થતા ક્લાયન્ટ દ્વારા જણાવવામાં આવે છે. વ્હાઇટલિસ્ટ એક્સપોઝર ઘટાડે છે અને પાસવર્ડ કે 2FA નો વિકલ્પ નથી"), ("whitelist_cidr_tip", "CIDR નોટેશન સપોર્ટેડ છે, ઉदાહરણ તરીકે 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "ચાલુ રાખો"), + ("Browser didn't open? Use the url below to sign in.", "બ્રાઉઝર ખૂલ્યું નથી? લોગિન કરવા માટે નીચે આપેલ URL નો ઉપયોગ કરો."), + ("Lock canvas", "કેનવાસ લોક કરો"), ].iter().cloned().collect(); } diff --git a/src/lang/he.rs b/src/lang/he.rs index 73e2dcef9..643359526 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "כתובת ה-IP שלך נחסמה על ידי הצד המרוחק"), ("id_whitelist_caveat_tip", "המזהה מדווח על ידי הלקוח המתחבר. הרשימה הלבנה מצמצמת חשיפה ואינה מחליפה סיסמה או 2FA"), ("whitelist_cidr_tip", "יש תמיכה בסימון CIDR, לדוגמה 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "המשך"), + ("Browser didn't open? Use the url below to sign in.", "הדפדפן לא נפתח? השתמש בכתובת שלמטה כדי להתחבר."), + ("Lock canvas", "נעל לוח ציור"), ].iter().cloned().collect(); } diff --git a/src/lang/hi.rs b/src/lang/hi.rs index d3e8dc399..250a6c963 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "आपका IP दूसरे पक्ष द्वारा अवरुद्ध कर दिया गया है"), ("id_whitelist_caveat_tip", "ID कनेक्ट करने वाले क्लाइंट द्वारा बताई जाती है। श्वेतसूची जोखिम कम करती है और पासवर्ड या 2FA का विकल्प नहीं है"), ("whitelist_cidr_tip", "CIDR नोटेशन समर्थित है, उदाहरण के लिए 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "जारी रखें"), + ("Browser didn't open? Use the url below to sign in.", "ब्राउज़र नहीं खुला? लॉगिन करने के लिए नीचे दिए गए URL का उपयोग करें।"), + ("Lock canvas", "कैनवास लॉक करें"), ].iter().cloned().collect(); } diff --git a/src/lang/hr.rs b/src/lang/hr.rs index 72cb3c466..46a559bc2 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Vašu IP adresu je blokiralo udaljeno računalo"), ("id_whitelist_caveat_tip", "ID prijavljuje klijent koji se povezuje. Ova lista smanjuje izloženost i ne zamjenjuje lozinku ni 2FA"), ("whitelist_cidr_tip", "Podržan je CIDR zapis, primjerice 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Nastavi"), + ("Browser didn't open? Use the url below to sign in.", "Preglednik se nije otvorio? Za prijavu upotrijebite URL u nastavku."), + ("Lock canvas", "Zaključaj pozadinu"), ].iter().cloned().collect(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 8bf6499ed..9e10eecb0 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Az IP-címét a távoli fél letiltotta"), ("id_whitelist_caveat_tip", "Az azonosítót a csatlakozó kliens jelenti. Az engedélyezési lista csökkenti a kitettséget, és nem helyettesíti a jelszót vagy a 2FA-t"), ("whitelist_cidr_tip", "A CIDR jelölés támogatott, például 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Folytatás"), + ("Browser didn't open? Use the url below to sign in.", "Nem nyílt meg a böngésző? A belépéshez használja az alábbi URL-címet."), + ("Lock canvas", "Nézet zárolása"), ].iter().cloned().collect(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index d1cca6374..ae313d69d 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "IP Anda diblokir oleh perangkat remote"), ("id_whitelist_caveat_tip", "ID dilaporkan oleh klien yang terhubung. Daftar ini mengurangi paparan dan bukan pengganti kata sandi atau 2FA"), ("whitelist_cidr_tip", "Notasi CIDR didukung, misalnya 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Lanjutkan"), + ("Browser didn't open? Use the url below to sign in.", "Browser tidak terbuka? Gunakan URL di bawah ini untuk masuk."), + ("Lock canvas", "Kunci kanvas"), ].iter().cloned().collect(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index c0567e4de..1cac8ccee 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -766,7 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("id_whitelist_caveat_tip", "L'ID è dichiarato dal client che si connette. Questo elenco riduce l'esposizione e non sostituisce la password o la 2FA"), ("whitelist_cidr_tip", "È supportata la notazione CIDR, ad esempio 192.168.1.0/24"), ("Continue", "Continua"), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Browser didn't open? Use the url below to sign in.", "Il browser non si è aperto? Usa l'URL qui sotto per accedere."), + ("Lock canvas", "Blocca tela"), ].iter().cloned().collect(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index 556d19adb..30cedf355 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "あなたの IP アドレスは接続先によってブロックされています"), ("id_whitelist_caveat_tip", "ID は接続するクライアントから申告されます。ホワイトリストは露出を減らすもので、パスワードや 2FA の代わりにはなりません"), ("whitelist_cidr_tip", "CIDR 表記に対応しています。例: 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "続行"), + ("Browser didn't open? Use the url below to sign in.", "ブラウザが開きませんでしたか?下記の URL からログインしてください。"), + ("Lock canvas", "キャンバスをロック"), ].iter().cloned().collect(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index 9ea26c293..ca23c65ac 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "귀하의 IP가 상대방에 의해 차단되었습니다"), ("id_whitelist_caveat_tip", "ID는 연결하는 클라이언트가 보고합니다. 화이트리스트는 노출을 줄이는 것으로 비밀번호나 2FA를 대체하지 않습니다"), ("whitelist_cidr_tip", "CIDR 표기를 지원합니다. 예: 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "계속"), + ("Browser didn't open? Use the url below to sign in.", "브라우저가 열리지 않았나요? 아래 URL로 로그인하세요."), + ("Lock canvas", "캔버스 잠금"), ].iter().cloned().collect(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index 5532f8368..a194ed19f 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Сіздің IP-мекенжайыңыз қарсы тараппен бұғатталған"), ("id_whitelist_caveat_tip", "ID қосылатын клиентпен хабарланады. Ақ-тізім әсер ету аумағын азайтады және құпия сөзді немесе 2FA-ны алмастырмайды"), ("whitelist_cidr_tip", "CIDR жазбасына қолдау көрсетіледі, мысалы 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Жалғастыру"), + ("Browser didn't open? Use the url below to sign in.", "Браузер ашылмады ма? Кіру үшін төмендегі сілтемені пайдаланыңыз."), + ("Lock canvas", "Кенепті құлыптау"), ].iter().cloned().collect(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 24d56ee90..4a638b697 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Jūsų IP adresą užblokavo nuotolinis įrenginys"), ("id_whitelist_caveat_tip", "ID praneša prisijungiantis klientas. Šis sąrašas sumažina atakos paviršių ir nepakeičia slaptažodžio ar 2FA"), ("whitelist_cidr_tip", "Palaikomas CIDR žymėjimas, pavyzdžiui 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Tęsti"), + ("Browser didn't open? Use the url below to sign in.", "Naršyklė neatsidarė? Prisijunkite naudodami toliau pateiktą URL."), + ("Lock canvas", "Užrakinti drobę"), ].iter().cloned().collect(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index f0fa78770..f0a3ebcff 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Jūsu IP adresi ir bloķējusi otra puse"), ("id_whitelist_caveat_tip", "ID paziņo klients, kas veido savienojumu. Baltais saraksts samazina pakļautību un neaizstāj paroli vai 2FA"), ("whitelist_cidr_tip", "Tiek atbalstīts CIDR pieraksts, piemēram 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Turpināt"), + ("Browser didn't open? Use the url below to sign in.", "Pārlūkprogramma neatvērās? Izmantojiet tālāk norādīto URL, lai pieslēgtos."), + ("Lock canvas", "Bloķēt audeklu"), ].iter().cloned().collect(); } diff --git a/src/lang/ml.rs b/src/lang/ml.rs index 5e69ac96a..b982ee49f 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "നിങ്ങളുടെ IP വിലാസം മറുവശം ബ്ലോക്ക് ചെയ്തിരിക്കുന്നു"), ("id_whitelist_caveat_tip", "കണക്റ്റ് ചെയ്യുന്ന ക്ലയന്റാണ് ID റിപ്പോർട്ട് ചെയ്യുന്നത്. വൈറ്റ്‌ലിസ്റ്റ് എക്സ്പോഷർ കുറയ്ക്കുന്നു; പാസ്‌വേഡിനോ 2FA-യ്ക്കോ പകരമല്ല"), ("whitelist_cidr_tip", "CIDR നൊട്ടേഷൻ പിന്തുണയ്ക്കുന്നു, ഉദാഹരണത്തിന് 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "തുടരുക"), + ("Browser didn't open? Use the url below to sign in.", "ബ്രൗസർ തുറന്നില്ലേ? ലോഗിൻ ചെയ്യാൻ താഴെയുള്ള URL ഉപയോഗിക്കുക."), + ("Lock canvas", "ക്യാൻവാസ് ലോക്ക് ചെയ്യുക"), ].iter().cloned().collect(); } diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 72c86ce0c..7dba8d4a5 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "IP-adressen din er blokkert av motparten"), ("id_whitelist_caveat_tip", "ID-en rapporteres av klienten som kobler til. Hvitelisten reduserer eksponeringen og erstatter ikke passord eller 2FA"), ("whitelist_cidr_tip", "CIDR-notasjon støttes, for eksempel 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Fortsett"), + ("Browser didn't open? Use the url below to sign in.", "Åpnet ikke nettleseren? Bruk URL-en nedenfor for å logge inn."), + ("Lock canvas", "Lås lerret"), ].iter().cloned().collect(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index eafd4033d..0470a4b4f 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -767,6 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "CIDR-notatie wordt ondersteund, bijv. 192.168.1.0/24"), ("Continue", "Doorgaan"), ("Browser didn't open? Use the url below to sign in.", "Is de browser niet geopend? Gebruik onderstaande URL om in te loggen."), - ("Lock canvas", ""), + ("Lock canvas", "Canvas vergrendelen"), ].iter().cloned().collect(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index bc1a231fa..44efea50c 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -766,7 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("id_whitelist_caveat_tip", "ID jest zgłaszane przez łączącego się klienta. Biała lista zmniejsza ekspozycję i nie zastępuje hasła ani 2FA"), ("whitelist_cidr_tip", "Obsługiwana jest notacja CIDR, na przykład 192.168.1.0/24"), ("Continue", "Kontynuuj"), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Browser didn't open? Use the url below to sign in.", "Przeglądarka się nie otworzyła? Użyj poniższego adresu URL, aby się zalogować."), + ("Lock canvas", "Zablokuj ekran"), ].iter().cloned().collect(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index debd5adb3..a162522bb 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "O seu IP está bloqueado pelo dispositivo remoto"), ("id_whitelist_caveat_tip", "O ID é comunicado pelo cliente que se liga. A whitelist reduz a exposição e não substitui a palavra-passe nem o 2FA"), ("whitelist_cidr_tip", "A notação CIDR é suportada, por exemplo 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Continuar"), + ("Browser didn't open? Use the url below to sign in.", "O navegador não abriu? Utilize o URL abaixo para iniciar sessão."), + ("Lock canvas", "Bloquear tela"), ].iter().cloned().collect(); } diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 629839c60..070fc0b7b 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -767,6 +767,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "A notação CIDR é suportada, por exemplo 192.168.1.0/24"), ("Continue", "Continuar"), ("Browser didn't open? Use the url below to sign in.", "O navegador não foi aberto? Use a URL abaixo para fazer login."), - ("Lock canvas", ""), + ("Lock canvas", "Bloquear tela"), ].iter().cloned().collect(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index 51522b093..5bc7a5e02 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Adresa ta IP este blocată de dispozitivul de la distanță"), ("id_whitelist_caveat_tip", "ID-ul este raportat de clientul care se conectează. Lista albă reduce expunerea și nu înlocuiește parola sau 2FA"), ("whitelist_cidr_tip", "Notația CIDR este acceptată, de exemplu 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Continuă"), + ("Browser didn't open? Use the url below to sign in.", "Browserul nu s-a deschis? Folosește URL-ul de mai jos pentru a te conecta."), + ("Lock canvas", "Blochează ecranul"), ].iter().cloned().collect(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index df71fbb51..a0ca5affe 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Ваш IP-адрес заблокирован удалённым устройством"), ("id_whitelist_caveat_tip", "ID сообщается подключающимся клиентом. Белый список уменьшает поверхность атаки и не заменяет пароль или 2FA"), ("whitelist_cidr_tip", "Поддерживается нотация CIDR, например 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Продолжить"), + ("Browser didn't open? Use the url below to sign in.", "Браузер не открылся? Используйте ссылку ниже для входа."), + ("Lock canvas", "Заблокировать холст"), ].iter().cloned().collect(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs index bfb06a332..1228e9876 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "S'indiritzu IP tuo est blocadu dae s'àtera parte"), ("id_whitelist_caveat_tip", "S'ID est decraradu dae su cliente chi si connetet. Custu elencu minimat s'espositzione e non sostituit sa crae o su 2FA"), ("whitelist_cidr_tip", "Sa notatzione CIDR est suportada, pro esempru 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Sighi"), + ("Browser didn't open? Use the url below to sign in.", "Non s'est abertu su navigadore? Imprea s'URL inoghe in suta pro intrare."), + ("Lock canvas", "Bloca sa tela"), ].iter().cloned().collect(); } diff --git a/src/lang/sk.rs b/src/lang/sk.rs index b3bac432c..5460e2b19 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Vaša IP adresa je blokovaná protistranou"), ("id_whitelist_caveat_tip", "ID nahlasuje pripájajúci sa klient. Tento zoznam znižuje vystavenie a nenahrádza heslo ani 2FA"), ("whitelist_cidr_tip", "Je podporovaný zápis CIDR, napríklad 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Pokračovať"), + ("Browser didn't open? Use the url below to sign in.", "Neotvoril sa prehliadač? Na prihlásenie použite URL nižšie."), + ("Lock canvas", "Uzamknúť zobrazenie"), ].iter().cloned().collect(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs index be4e7b3d2..d9d5fd172 100644 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Vaš IP je blokirala oddaljena naprava"), ("id_whitelist_caveat_tip", "ID sporoči odjemalec, ki se povezuje. Seznam zmanjšuje izpostavljenost in ne nadomešča gesla ali 2FA"), ("whitelist_cidr_tip", "Podprt je zapis CIDR, na primer 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Nadaljuj"), + ("Browser didn't open? Use the url below to sign in.", "Brskalnik se ni odprl? Za prijavo uporabite spodnji URL."), + ("Lock canvas", "Zakleni platno"), ].iter().cloned().collect(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 9b61e19ff..40b1060e7 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "IP-ja juaj është bllokuar nga pala tjetër"), ("id_whitelist_caveat_tip", "ID-ja raportohet nga klienti që lidhet. Lista e bardhë zvogëlon ekspozimin dhe nuk zëvendëson fjalëkalimin ose 2FA"), ("whitelist_cidr_tip", "Mbështetet shënimi CIDR, për shembull 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Vazhdo"), + ("Browser didn't open? Use the url below to sign in.", "Shfletuesi nuk u hap? Përdorni URL-në më poshtë për të hyrë."), + ("Lock canvas", "Kyç canvas"), ].iter().cloned().collect(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index f3a4521b8..390afb5e5 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Vašu IP adresu je blokirala druga strana"), ("id_whitelist_caveat_tip", "ID prijavljuje klijent koji se povezuje. Ova lista smanjuje izloženost i ne zamenjuje lozinku ni 2FA"), ("whitelist_cidr_tip", "Podržan je CIDR zapis, na primer 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Nastavi"), + ("Browser didn't open? Use the url below to sign in.", "Pregledač se nije otvorio? Za prijavu koristite URL ispod."), + ("Lock canvas", "Zaključaj pozadinu"), ].iter().cloned().collect(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 236624134..142f18338 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Din IP-adress är blockerad av motparten"), ("id_whitelist_caveat_tip", "ID:t rapporteras av klienten som ansluter. Vitlistan minskar exponeringen och ersätter inte lösenord eller 2FA"), ("whitelist_cidr_tip", "CIDR-notation stöds, till exempel 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Fortsätt"), + ("Browser didn't open? Use the url below to sign in.", "Öppnades inte webbläsaren? Använd URL:en nedan för att logga in."), + ("Lock canvas", "Lås canvas"), ].iter().cloned().collect(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs index 779466e60..c6221a841 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "உங்கள் IP முகவரி மறுமுனையால் தடுக்கப்பட்டுள்ளது"), ("id_whitelist_caveat_tip", "இணைக்கும் கிளையண்டே ID-ஐ தெரிவிக்கிறது. அனுமதிப்பட்டியல் வெளிப்பாட்டைக் குறைக்கிறது; கடவுச்சொல் அல்லது 2FA-க்கு மாற்றாகாது"), ("whitelist_cidr_tip", "CIDR குறியீடு ஆதரிக்கப்படுகிறது, எடுத்துக்காட்டாக 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "தொடர்க"), + ("Browser didn't open? Use the url below to sign in.", "உலாவி திறக்கவில்லையா? உள்நுழைய கீழே உள்ள URL ஐப் பயன்படுத்தவும்."), + ("Lock canvas", "கேன்வாஸைப் பூட்டு"), ].iter().cloned().collect(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index dc8d2e863..d956e7144 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "IP ของคุณถูกบล็อกโดยฝั่งตรงข้าม"), ("id_whitelist_caveat_tip", "ID ถูกรายงานโดยไคลเอนต์ที่เชื่อมต่อ ไวท์ลิสต์ช่วยลดการเปิดเผยและไม่สามารถใช้แทนรหัสผ่านหรือ 2FA ได้"), ("whitelist_cidr_tip", "รองรับรูปแบบ CIDR เช่น 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "ดำเนินการต่อ"), + ("Browser didn't open? Use the url below to sign in.", "เบราว์เซอร์ไม่เปิดใช่ไหม? ใช้ URL ด้านล่างเพื่อเข้าสู่ระบบ"), + ("Lock canvas", "ล็อคแคนวาส"), ].iter().cloned().collect(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 6394e1c45..1f0c3dcd8 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "IP adresiniz karşı taraf tarafından engellendi"), ("id_whitelist_caveat_tip", "ID, bağlanan istemci tarafından bildirilir. Bu liste maruziyeti azaltır; parolanın veya 2FA'nın yerini tutmaz"), ("whitelist_cidr_tip", "CIDR gösterimi desteklenir, örneğin 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Devam et"), + ("Browser didn't open? Use the url below to sign in.", "Tarayıcı açılmadı mı? Giriş yapmak için aşağıdaki URL'yi kullanın."), + ("Lock canvas", "Tuvali kilitle"), ].iter().cloned().collect(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 2f8675456..4b61936e4 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Вашу IP-адресу заблоковано віддаленим пристроєм"), ("id_whitelist_caveat_tip", "ID повідомляється клієнтом, що підключається. Білий список зменшує поверхню атаки і не замінює пароль або 2FA"), ("whitelist_cidr_tip", "Підтримується нотація CIDR, наприклад 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Продовжити"), + ("Browser didn't open? Use the url below to sign in.", "Браузер не відкрився? Скористайтеся посиланням нижче, щоб увійти."), + ("Lock canvas", "Блокування полотна"), ].iter().cloned().collect(); } diff --git a/src/lang/vi.rs b/src/lang/vi.rs index b5e8bd573..a3102800b 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -765,8 +765,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "IP của bạn đã bị phía bên kia chặn"), ("id_whitelist_caveat_tip", "ID do máy khách kết nối tự khai báo. Danh sách trắng giúp giảm mức độ lộ diện và không thay thế mật khẩu hay 2FA"), ("whitelist_cidr_tip", "Hỗ trợ ký hiệu CIDR, ví dụ 192.168.1.0/24"), - ("Continue", ""), - ("Browser didn't open? Use the url below to sign in.", ""), - ("Lock canvas", ""), + ("Continue", "Tiếp tục"), + ("Browser didn't open? Use the url below to sign in.", "Trình duyệt không mở được? Hãy dùng URL bên dưới để đăng nhập."), + ("Lock canvas", "Khóa khung hình"), ].iter().cloned().collect(); } From 6a27910f34aa2e0280e77dac1170573947bea6ff Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:57:06 +0800 Subject: [PATCH 32/72] fix(wayland): back off the polling display lookups after a failure (drm) (#15865) * fix(wayland): back off the polling display lookups after a failure (drm) In drm builds an enumeration that fails with no endpoint named in the environment falls back to the socket probe, which forks a child bounded by seconds, and the display service asks again every 300 ms -- at a greeter with no reachable compositor that is a probe child per turn, forever. Such a failure now stamps a shared 5 s backoff, and only the polling callers honor it: the 300 ms displays-changed check skips its turn and the 1.5 s live layout poll returns no answer for that turn. Only the failure that would fork stamps. A session server is spawned with WAYLAND_DISPLAY set, so its failed connect bails in-process before any fork; stamping there would buy nothing and cost recovery latency, so live sessions keep master's behavior exactly. The stamp also survives clear_wayland_displays_cache: it describes the seat, not the cache, and the ~1/s capturer rebuild loop clears on every teardown -- dropping the stamp with the cache would let that loop defeat the backoff and would turn every post-hotplug failure into a "first" one forever. The displays-changed check weighs the backoff against what is already published. With nothing synced yet it always populates -- an unaugmented DRM list beats the empty broadcast the send path would otherwise emit. With a synced layout, a suppressed turn keeps it, and a fresh first failure keeps it too; only a failure that persists across a backoff replaces it with the DRM stack, so a hotplug at a failing seat converges within one backoff while a transient failure never tears down a good layout. One-shot callers -- session init, pipewire stream setup, capturer info -- keep probing fresh through get_displays, whose failure semantics are unchanged: replaying a transient failure there would latch an empty answer into session-long state. Non-drm builds compile none of this. Co-Authored-By: Claude Fable 5 * fix(wayland): log DRM lookup failure once * fix(wayland): reset lookup warning after recovery --------- Co-authored-by: Claude Fable 5 --- libs/scrap/src/wayland/display.rs | 138 +++++++++++++++++++++++++++--- src/server/display_service.rs | 17 +++- 2 files changed, 143 insertions(+), 12 deletions(-) diff --git a/libs/scrap/src/wayland/display.rs b/libs/scrap/src/wayland/display.rs index bed90fd76..1a9f29f25 100644 --- a/libs/scrap/src/wayland/display.rs +++ b/libs/scrap/src/wayland/display.rs @@ -19,6 +19,18 @@ static MISSING_LOGICAL_SIZE_WARNED: std::sync::atomic::AtomicBool = const COMMAND_TIMEOUT: Duration = Duration::from_millis(1000); +// drm builds only: an unnamed-endpoint failure there forks the probe child, and the pollers +// turn every few hundred milliseconds. Every other failure is one cheap in-process error. +#[cfg(any(test, feature = "drm"))] +const FAILED_LOOKUP_BACKOFF: Duration = Duration::from_secs(5); + +#[cfg(any(test, feature = "drm"))] +static LAST_FAILED_LOOKUP: Mutex> = Mutex::new(None); + +#[cfg(feature = "drm")] +static LOOKUP_FAILURE_WARNED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + pub struct Displays { pub primary: usize, pub displays: Vec, @@ -171,11 +183,76 @@ fn get_primary_monitor() -> Option { .or_else(try_gdbus_primary) } +// Pure, so the backoff policy is testable without a compositor. +#[cfg(any(test, feature = "drm"))] +fn lookup_allowed(failed_at: Option, now: Instant) -> bool { + failed_at.map_or(true, |at| { + now.saturating_duration_since(at) >= FAILED_LOOKUP_BACKOFF + }) +} + +#[cfg(feature = "drm")] +fn backed_off() -> bool { + let failed_at = *LAST_FAILED_LOOKUP.lock().unwrap(); + !lookup_allowed(failed_at, Instant::now()) +} + +// Mirrors the probe module's gate, latch included: connecting consumes WAYLAND_SOCKET, so a +// once-named endpoint must stay named for the life of the process. +#[cfg(feature = "drm")] +fn endpoint_named() -> bool { + use std::sync::atomic::{AtomicBool, Ordering}; + static WAS_NAMED: AtomicBool = AtomicBool::new(false); + let named = ["WAYLAND_DISPLAY", "WAYLAND_SOCKET"] + .iter() + .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty())); + if named { + WAS_NAMED.store(true, Ordering::Release); + } + WAS_NAMED.load(Ordering::Acquire) +} + +// Enumerates and keeps the failure stamp current. Suppresses nothing itself: one-shot callers +// (session init, pipewire) must always get a fresh read, or a transient failure latches. +fn enumerate_displays() -> hbb_common::ResultType> { + // Read before connecting, which consumes WAYLAND_SOCKET. + #[cfg(feature = "drm")] + let named = endpoint_named(); + let probed = get_wayland_displays(); + // Only the failure that would fork stamps; a named endpoint fails cheaply in-process. + #[cfg(feature = "drm")] + { + *LAST_FAILED_LOOKUP.lock().unwrap() = (probed.is_err() && !named).then(Instant::now); + if let Err(err) = &probed { + if !LOOKUP_FAILURE_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) { + warn!("Failed to get wayland displays: {}", err); + } + } else { + LOOKUP_FAILURE_WARNED.store(false, std::sync::atomic::Ordering::Relaxed); + } + } + probed +} + +// True when a lookup now could neither hit the cache nor probe. Pollers skip their turn on +// it and keep their last published state; one-shot callers must not consult it. +#[cfg(feature = "drm")] +pub fn wayland_lookup_suppressed() -> bool { + DISPLAYS.lock().unwrap().is_none() && backed_off() +} + +// Whether any failure stamp exists, expired or not: pollers use it to tell a first failure +// from one that has already persisted across a backoff. +#[cfg(feature = "drm")] +pub fn wayland_failure_stamped() -> bool { + LAST_FAILED_LOOKUP.lock().unwrap().is_some() +} + pub fn get_displays() -> Arc { let mut lock = DISPLAYS.lock().unwrap(); match lock.as_ref() { Some(displays) => displays.clone(), - None => match get_wayland_displays() { + None => match enumerate_displays() { Ok(displays) => { let mut primary_index = None; if let Some(name) = get_primary_monitor() { @@ -201,8 +278,9 @@ pub fn get_displays() -> Arc { *lock = Some(displays.clone()); displays } - Err(err) => { - warn!("Failed to get wayland displays: {}", err); + Err(_err) => { + #[cfg(not(feature = "drm"))] + warn!("Failed to get wayland displays: {}", _err); Arc::new(Displays { primary: 0, displays: Vec::new(), @@ -215,6 +293,8 @@ pub fn get_displays() -> Arc { #[inline] pub fn clear_wayland_displays_cache() { let _ = DISPLAYS.lock().unwrap().take(); + // The failure stamp survives on purpose: it describes the seat, not the cache, and the + // capturer rebuild loop clears about once a second. } // Return (min_x, max_x, min_y, max_y) @@ -223,17 +303,21 @@ pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { desktop_rect_of(&wayland_displays.displays) } -// The desktop rect and per-display logical rects, always read live from the -// compositor in a single roundtrip. Skips the displays cache and the primary-monitor -// detection (which may spawn external commands), so it is cheap enough to poll for -// layout changes. https://github.com/rustdesk/rustdesk/issues/15601 +// The desktop rect and per-display logical rects, read live from the compositor in a single +// roundtrip (drm builds may skip a turn during the failure backoff). Skips the displays cache +// and the primary-monitor detection, cheap enough to poll. rustdesk/rustdesk#15601 pub fn get_layout_for_uinput_live() -> Option<((i32, i32, i32, i32), Vec)> { - match get_wayland_displays() { + #[cfg(feature = "drm")] + if backed_off() { + return None; + } + match enumerate_displays() { Ok(displays) => { desktop_rect_of(&displays).map(|rect| (rect, logical_rects_of(&displays))) } - Err(err) => { - warn!("Failed to get wayland displays: {}", err); + Err(_err) => { + #[cfg(not(feature = "drm"))] + warn!("Failed to get wayland displays: {}", _err); None } } @@ -386,6 +470,40 @@ fn map_axis(v: i32, base_origin: i32, base_extent: i32, live_origin: i32, live_e mod tests { use super::*; + #[test] + fn test_lookup_backoff_boundaries() { + // Future `now`s sidestep Instant subtraction, which can panic near boot. + let failed_at = Instant::now(); + assert!(lookup_allowed(None, failed_at)); + assert!(!lookup_allowed( + Some(failed_at), + failed_at + FAILED_LOOKUP_BACKOFF / 2 + )); + assert!(lookup_allowed( + Some(failed_at), + failed_at + FAILED_LOOKUP_BACKOFF + )); + } + + #[test] + fn test_lookup_stamp_from_the_future_only_waits() { + // saturating_duration_since answers zero rather than underflowing. + let now = Instant::now(); + assert!(!lookup_allowed(Some(now + FAILED_LOOKUP_BACKOFF), now)); + } + + #[test] + fn test_clear_keeps_the_failure_stamp() { + // The stamp describes the seat, not the cache: the ~1/s capturer rebuild loop clears, + // and dropping the stamp with it would defeat the backoff. Sole test touching these + // statics; serialize before adding another. + *LAST_FAILED_LOOKUP.lock().unwrap() = Some(Instant::now()); + clear_wayland_displays_cache(); + let stamp = *LAST_FAILED_LOOKUP.lock().unwrap(); + assert!(stamp.is_some()); + *LAST_FAILED_LOOKUP.lock().unwrap() = None; + } + fn display( x: i32, y: i32, diff --git a/src/server/display_service.rs b/src/server/display_service.rs index 7572caf10..235c7ca86 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -346,8 +346,21 @@ fn check_get_displays_changed_msg() -> Option { // list that overwrites the login peer-info displays and the client shows "No displays". #[cfg(feature = "drm")] if super::drm_capturer::is_available_cached() { - if let Some(displays) = super::drm_capturer::get_display_infos() { - SYNC_DISPLAYS.lock().unwrap().check_changed(&displays); + let synced = !SYNC_DISPLAYS.lock().unwrap().displays.is_empty(); + let stamped_before = scrap::wayland::display::wayland_failure_stamped(); + // With nothing published yet, even the unaugmented DRM list beats the empty + // broadcast below; with a synced layout, a suppressed turn keeps it instead. + if !synced || !scrap::wayland::display::wayland_lookup_suppressed() { + if let Some(displays) = super::drm_capturer::get_display_infos() { + // A first failure keeps the synced layout for one backoff; only a + // failure that persists across one replaces it with the DRM stack. + if !synced + || stamped_before + || !scrap::wayland::display::wayland_lookup_suppressed() + { + SYNC_DISPLAYS.lock().unwrap().check_changed(&displays); + } + } } } return get_displays_msg(); From 9b1b810d3a052e1897e10a701cb3e938dd69465a Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:40:04 +0800 Subject: [PATCH 33/72] Delete .github/dependabot.yml (#15888) --- .github/dependabot.yml | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 56258e4e0..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "gitsubmodule" - directory: "/" - target-branch: "master" - schedule: - interval: "daily" - commit-message: - prefix: "Git submodule" - labels: - - "dependencies" From 0c00d576dd104cb83b10f492be2f0ec790c0183a Mon Sep 17 00:00:00 2001 From: rustdesk Date: Tue, 18 Aug 2026 12:31:59 +0800 Subject: [PATCH 34/72] improve comment rules --- AGENTS.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7ab98087d..a32b940ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,10 +63,9 @@ ### Comments -* Keep them short: one line by default, three at most. -* Say **why**, never what. If the code already says it, delete the comment. -* A comment must never be longer than the code it describes. -* Applies to YAML, shell and Python too, not just Rust. +* Avoid comments unless they explain a non-obvious reason, constraint, or workaround. +* Never restate what the code does; prefer clearer code instead. +* If the code is self-explanatory, add no comment. ### Be minimally invasive From b0008edcb57712eaf95dbcc3ee610ab8727a9919 Mon Sep 17 00:00:00 2001 From: fufesou Date: Tue, 18 Aug 2026 15:02:50 +0800 Subject: [PATCH 35/72] refact: remove linux headless (#15866) * refact: remove linux headless Signed-off-by: fufesou * fix(linux): probe DRM availability asynchronously on login Signed-off-by: fufesou * revert changes in drm_capturer.rs Signed-off-by: fufesou * Update submodule hbb_common Signed-off-by: fufesou * docs(linux): clarify DRM availability comments Remove stale headless and unauthenticated-request wording, and document the Available-only login-screen gate. Signed-off-by: fufesou * fix(linux): remove unreachable session cleanup branch Remove the obsolete empty-session path and clarify the intended use of cached DRM availability. Signed-off-by: fufesou --------- Signed-off-by: fufesou --- .github/workflows/ci.yml | 1 - .github/workflows/flutter-build.yml | 5 - .github/workflows/playground.yml | 1 - Cargo.lock | 44 +- Cargo.toml | 1 - Dockerfile | 1 - README.md | 6 +- appimage/AppImageBuilder-aarch64.yml | 1 - appimage/AppImageBuilder-x86_64.yml | 1 - build.py | 16 +- docs/README-DE.md | 6 +- docs/README-ES.md | 6 +- docs/README-KR.md | 6 +- docs/README-NO.md | 6 +- docs/README-PTBR.md | 6 +- docs/README-RO.md | 6 +- docs/README-RU.md | 8 +- docs/README-UA.md | 6 +- flatpak/rustdesk.json | 14 +- flutter/lib/common/widgets/dialog.dart | 123 +- flutter/lib/common/widgets/toolbar.dart | 14 +- flutter/lib/consts.dart | 2 - .../desktop/pages/desktop_setting_page.dart | 4 - flutter/lib/models/model.dart | 10 +- flutter/lib/web/bridge.dart | 4 - libs/hbb_common | 2 +- res/PKGBUILD | 2 +- res/pam.d/rustdesk.debian | 5 - res/pam.d/rustdesk.suse | 5 - res/rpm-flutter-suse.spec | 2 +- res/rpm-flutter.spec | 2 +- res/rpm-suse.spec | 2 +- res/rpm.spec | 2 +- res/startwm.sh | 130 -- res/xorg.conf | 30 - src/client.rs | 84 +- src/common.rs | 2 +- src/core_main.rs | 8 - src/flutter.rs | 22 +- src/flutter_ffi.rs | 8 - src/lang/ar.rs | 10 - src/lang/be.rs | 10 - src/lang/bg.rs | 10 - src/lang/ca.rs | 10 - src/lang/cn.rs | 10 - src/lang/cs.rs | 10 - src/lang/da.rs | 10 - src/lang/de.rs | 10 - src/lang/el.rs | 10 - src/lang/en.rs | 10 - src/lang/eo.rs | 10 - src/lang/es.rs | 10 - src/lang/et.rs | 10 - src/lang/eu.rs | 10 - src/lang/fa.rs | 10 - src/lang/fi.rs | 10 - src/lang/fr.rs | 10 - src/lang/ge.rs | 10 - src/lang/gu.rs | 10 - src/lang/he.rs | 10 - src/lang/hi.rs | 10 - src/lang/hr.rs | 10 - src/lang/hu.rs | 10 - src/lang/id.rs | 10 - src/lang/it.rs | 10 - src/lang/ja.rs | 10 - src/lang/ko.rs | 10 - src/lang/kz.rs | 10 - src/lang/lt.rs | 10 - src/lang/lv.rs | 10 - src/lang/ml.rs | 10 - src/lang/nb.rs | 10 - src/lang/nl.rs | 10 - src/lang/pl.rs | 10 - src/lang/pt_PT.rs | 10 - src/lang/ptbr.rs | 10 - src/lang/ro.rs | 10 - src/lang/ru.rs | 10 - src/lang/sc.rs | 10 - src/lang/sk.rs | 10 - src/lang/sl.rs | 10 - src/lang/sq.rs | 10 - src/lang/sr.rs | 10 - src/lang/sv.rs | 10 - src/lang/ta.rs | 10 - src/lang/template.rs | 10 - src/lang/th.rs | 10 - src/lang/tr.rs | 10 - src/lang/tw.rs | 10 - src/lang/uk.rs | 10 - src/lang/vi.rs | 10 - src/platform/linux.rs | 65 +- src/platform/linux_desktop_manager.rs | 1363 ----------------- src/platform/mod.rs | 3 - src/rendezvous_mediator.rs | 5 - src/server/connection.rs | 424 +---- src/server/drm_capturer.rs | 20 +- src/ui/common.tis | 24 - src/ui/msgbox.tis | 59 +- 99 files changed, 133 insertions(+), 2944 deletions(-) delete mode 100644 res/pam.d/rustdesk.debian delete mode 100644 res/pam.d/rustdesk.suse delete mode 100755 res/startwm.sh delete mode 100644 res/xorg.conf delete mode 100644 src/platform/linux_desktop_manager.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 173eda9f4..3e8373cdc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,7 +124,6 @@ jobs: gcc \ git \ g++ \ - libpam0g-dev \ libasound2-dev \ libunwind-dev \ libgstreamer1.0-dev \ diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 81a448a90..3a76412da 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -1009,7 +1009,6 @@ jobs: libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ libgtk-3-dev \ - libpam0g-dev \ libpulse-dev \ libva-dev \ libxcb-randr0-dev \ @@ -1283,7 +1282,6 @@ jobs: libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ libgtk-3-dev \ - libpam0g-dev \ libpulse-dev \ libva-dev \ libxcb-randr0-dev \ @@ -1574,7 +1572,6 @@ jobs: libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ libgtk-3-dev \ - libpam0g-dev \ libpulse-dev \ libva-dev \ libxcb-randr0-dev \ @@ -1896,7 +1893,6 @@ jobs: libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ libgtk-3-dev \ - libpam0g-dev \ libpulse-dev \ libva-dev \ libxcb-randr0-dev \ @@ -2126,7 +2122,6 @@ jobs: libgstreamer-plugins-base1.0-dev \ libgtk-3-dev \ liblzma-dev \ - libpam0g-dev \ libpulse-dev \ libva-dev \ libxcb-randr0-dev \ diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml index 41b9c0c13..765bcf7f7 100644 --- a/.github/workflows/playground.yml +++ b/.github/workflows/playground.yml @@ -271,7 +271,6 @@ jobs: libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ libgtk-3-dev \ - libpam0g-dev \ libpulse-dev \ libva-dev \ libvdpau-dev \ diff --git a/Cargo.lock b/Cargo.lock index 9272b562a..7448d84c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3791,7 +3791,7 @@ dependencies = [ "toml 0.7.8", "tungstenite", "url", - "users 0.11.0", + "users", "uuid", "webpki-roots 1.0.9", "webrtc", @@ -5932,37 +5932,6 @@ dependencies = [ "winapi 0.3.9", ] -[[package]] -name = "pam" -version = "0.7.0" -source = "git+https://github.com/rustdesk-org/pam#7bfd25510202cd269292cbdd7c71f3977a6fd762" -dependencies = [ - "libc", - "pam-macros", - "pam-sys", - "users 0.10.0", -] - -[[package]] -name = "pam-macros" -version = "0.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c94f3b9b97df3c6d4e51a14916639b24e02c7d15d1dba686ce9b1118277cb811" -dependencies = [ - "proc-macro2 1.0.93", - "quote 1.0.36", - "syn 1.0.109", -] - -[[package]] -name = "pam-sys" -version = "1.0.0-alpha4" -source = "git+https://github.com/rustdesk-org/pam-sys?branch=fix/v1.0.0-alpha4_gnuc_va_list#3337c9bb9a9c68d7497ec8c93cad2368c26091b7" -dependencies = [ - "bindgen 0.59.2", - "libc", -] - [[package]] name = "pango" version = "0.18.3" @@ -7266,7 +7235,6 @@ dependencies = [ "once_cell", "openssl", "os-version", - "pam", "parity-tokio-ipc", "percent-encoding", "piet", @@ -9301,16 +9269,6 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" -[[package]] -name = "users" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa4227e95324a443c9fcb06e03d4d85e91aabe9a5a02aa818688b6918b6af486" -dependencies = [ - "libc", - "log", -] - [[package]] name = "users" version = "0.11.0" diff --git a/Cargo.toml b/Cargo.toml index 588cbd96a..7d08cb3f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -189,7 +189,6 @@ async-process = "1.7" evdev = { git="https://github.com/rustdesk-org/evdev" } dbus = "0.9" dbus-crossroads = "0.5" -pam = { git="https://github.com/rustdesk-org/pam" } x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true} x11rb = {version = "0.12", features = ["all-extensions"], optional = true} percent-encoding = {version = "2.3", optional = true} diff --git a/Dockerfile b/Dockerfile index f0e4e4a4a..e6c95ad52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,6 @@ RUN apt update -y && \ libxcb-shape0-dev \ libxcb-xfixes0-dev \ libasound2-dev \ - libpam0g-dev \ libpulse-dev \ make \ wget \ diff --git a/README.md b/README.md index 1bb120c81..08f3f9d57 100644 --- a/README.md +++ b/README.md @@ -66,19 +66,19 @@ Please download Sciter dynamic library yourself. ```sh sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ - libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev + libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) diff --git a/appimage/AppImageBuilder-aarch64.yml b/appimage/AppImageBuilder-aarch64.yml index bad4e84db..1ccb51665 100644 --- a/appimage/AppImageBuilder-aarch64.yml +++ b/appimage/AppImageBuilder-aarch64.yml @@ -58,7 +58,6 @@ AppDir: - libpulse0 - packagekit-gtk3-module - libcanberra-gtk3-module - - libpam0g - libdrm2 exclude: - humanity-icon-theme diff --git a/appimage/AppImageBuilder-x86_64.yml b/appimage/AppImageBuilder-x86_64.yml index 7cd52b89a..30b48e7da 100644 --- a/appimage/AppImageBuilder-x86_64.yml +++ b/appimage/AppImageBuilder-x86_64.yml @@ -61,7 +61,6 @@ AppDir: - libpulse0 - packagekit-gtk3-module - libcanberra-gtk3-module - - libpam0g - libdrm2 exclude: - humanity-icon-theme diff --git a/build.py b/build.py index 6b770f993..4f1953662 100755 --- a/build.py +++ b/build.py @@ -364,7 +364,7 @@ Version: %s Architecture: %s Maintainer: rustdesk Homepage: https://rustdesk.com -Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s +Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, gstreamer1.0-pipewire%s Recommends: libayatana-appindicator3-1 Description: A remote control software. @@ -704,8 +704,6 @@ def build_flutter_deb(version, features): system2('flutter build linux --release') system2('mkdir -p tmpdeb/usr/bin/') system2('mkdir -p tmpdeb/usr/share/rustdesk') - system2('mkdir -p tmpdeb/etc/rustdesk/') - system2('mkdir -p tmpdeb/etc/pam.d/') system2('mkdir -p tmpdeb/usr/share/rustdesk/files/systemd/') system2('mkdir -p tmpdeb/usr/share/icons/hicolor/256x256/apps/') system2('mkdir -p tmpdeb/usr/share/icons/hicolor/scalable/apps/') @@ -724,12 +722,6 @@ def build_flutter_deb(version, features): 'cp ../res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop') system2( 'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop') - system2( - 'cp ../res/startwm.sh tmpdeb/etc/rustdesk/') - system2( - 'cp ../res/xorg.conf tmpdeb/etc/rustdesk/') - system2( - 'cp ../res/pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk') system2( "echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit") # Bundle libdrmtap.so only when this build actually enabled the `drm` feature, so stock packages @@ -1132,13 +1124,7 @@ def main(): 'cp res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop') system2( 'cp res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop') - os.system('mkdir -p tmpdeb/etc/rustdesk/') - os.system('cp -a res/startwm.sh tmpdeb/etc/rustdesk/') - os.system('mkdir -p tmpdeb/etc/X11/rustdesk/') - os.system('cp res/xorg.conf tmpdeb/etc/X11/rustdesk/') os.system('cp -a DEBIAN/* tmpdeb/DEBIAN/') - os.system('mkdir -p tmpdeb/etc/pam.d/') - os.system('cp pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk') system2('strip tmpdeb/usr/bin/rustdesk') system2('mkdir -p tmpdeb/usr/share/rustdesk') system2('mv tmpdeb/usr/bin/rustdesk tmpdeb/usr/share/rustdesk/') diff --git a/docs/README-DE.md b/docs/README-DE.md index f76e00fe5..91ba5a08c 100644 --- a/docs/README-DE.md +++ b/docs/README-DE.md @@ -66,19 +66,19 @@ Bitte laden Sie die dynamische Bibliothek Sciter selbst herunter. ```sh sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ - libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev + libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) diff --git a/docs/README-ES.md b/docs/README-ES.md index 88cce46ad..bdf099ffd 100644 --- a/docs/README-ES.md +++ b/docs/README-ES.md @@ -62,19 +62,19 @@ Por favor descarga la librería dinámica de Sciter tú mismo. ```sh sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ - libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev + libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) diff --git a/docs/README-KR.md b/docs/README-KR.md index 354cfe708..687ba24e6 100644 --- a/docs/README-KR.md +++ b/docs/README-KR.md @@ -66,19 +66,19 @@ Sciter 동적 라이브러리를 직접 다운로드하세요. ```sh sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ - libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev + libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) diff --git a/docs/README-NO.md b/docs/README-NO.md index 9aac6d943..609795996 100644 --- a/docs/README-NO.md +++ b/docs/README-NO.md @@ -62,19 +62,19 @@ Venligst last ned Sciters dynamiske bibliotek selv. ```sh sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ - libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev + libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) diff --git a/docs/README-PTBR.md b/docs/README-PTBR.md index bd16806ef..332967ea2 100644 --- a/docs/README-PTBR.md +++ b/docs/README-PTBR.md @@ -64,19 +64,19 @@ Por favor, faça o download da biblioteca dinâmica do Sciter por conta própria ### Ubuntu 18 (Debian 10) ```sh -sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev +sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) diff --git a/docs/README-RO.md b/docs/README-RO.md index d2b748e47..0f2f17466 100644 --- a/docs/README-RO.md +++ b/docs/README-RO.md @@ -66,19 +66,19 @@ Te rugăm să descarci singur librăria dinamică Sciter. ```sh sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ - libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev + libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) diff --git a/docs/README-RU.md b/docs/README-RU.md index c3c208066..e3e97d8ca 100644 --- a/docs/README-RU.md +++ b/docs/README-RU.md @@ -68,19 +68,19 @@ RustDesk приветствует вклад каждого. Ознакомьт ```sh sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ - libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev + libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) @@ -179,4 +179,4 @@ target/release/rustdesk ![Передача файлов](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad) -![TCP-туннелирование](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) \ No newline at end of file +![TCP-туннелирование](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) diff --git a/docs/README-UA.md b/docs/README-UA.md index 3da69acad..12d98dbdf 100644 --- a/docs/README-UA.md +++ b/docs/README-UA.md @@ -59,19 +59,19 @@ RustDesk вітає внесок кожного. Ознайомтеся з [CONT ```sh sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ - libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev + libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) diff --git a/flatpak/rustdesk.json b/flatpak/rustdesk.json index 2418ac2a6..108a4ba1d 100644 --- a/flatpak/rustdesk.json +++ b/flatpak/rustdesk.json @@ -21,18 +21,6 @@ } ] }, - { - "name": "pam", - "buildsystem": "autotools", - "config-opts": ["--disable-selinux"], - "sources": [ - { - "type": "archive", - "url": "https://github.com/linux-pam/linux-pam/releases/download/v1.3.1/Linux-PAM-1.3.1.tar.xz", - "sha256": "eff47a4ecd833fbf18de9686632a70ee8d0794b79aecb217ebd0ce11db4cd0db" - } - ] - }, { "name": "rustdesk", "buildsystem": "simple", @@ -63,4 +51,4 @@ "--socket=pulseaudio", "--talk-name=org.freedesktop.Flatpak" ] -} \ No newline at end of file +} diff --git a/flutter/lib/common/widgets/dialog.dart b/flutter/lib/common/widgets/dialog.dart index f80603802..f009c051c 100644 --- a/flutter/lib/common/widgets/dialog.dart +++ b/flutter/lib/common/widgets/dialog.dart @@ -936,26 +936,19 @@ void enterPasswordDialog( ); } -void enterUserLoginDialog( - SessionID sessionId, - OverlayDialogManager dialogManager, - String osAccountDescTip, - bool canRememberAccount) async { +void enterUserLoginDialog(SessionID sessionId, + OverlayDialogManager dialogManager, String osAccountDescTip) async { await _connectDialog( sessionId, dialogManager, osUsernameController: TextEditingController(), osPasswordController: TextEditingController(), osAccountDescTip: osAccountDescTip, - canRememberAccount: canRememberAccount, ); } -void enterUserLoginAndPasswordDialog( - SessionID sessionId, - OverlayDialogManager dialogManager, - String osAccountDescTip, - bool canRememberAccount) async { +void enterUserLoginAndPasswordDialog(SessionID sessionId, + OverlayDialogManager dialogManager, String osAccountDescTip) async { await _connectDialog( sessionId, dialogManager, @@ -963,7 +956,6 @@ void enterUserLoginAndPasswordDialog( osPasswordController: TextEditingController(), passwordController: TextEditingController(), osAccountDescTip: osAccountDescTip, - canRememberAccount: canRememberAccount, ); } @@ -974,7 +966,6 @@ _connectDialog( TextEditingController? osPasswordController, TextEditingController? passwordController, String? osAccountDescTip, - bool canRememberAccount = true, }) async { final errUsername = ''.obs; var rememberPassword = false; @@ -982,11 +973,6 @@ _connectDialog( rememberPassword = await bind.sessionGetRemember(sessionId: sessionId) ?? false; } - var rememberAccount = false; - if (canRememberAccount && osUsernameController != null) { - rememberAccount = - await bind.sessionGetRemember(sessionId: sessionId) ?? false; - } if (osUsernameController != null) { osUsernameController.addListener(() { if (errUsername.value.isNotEmpty) { @@ -1014,12 +1000,6 @@ _connectDialog( final osPassword = osPasswordController?.text.trim() ?? ''; final password = passwordController?.text.trim() ?? ''; if (passwordController != null && password.isEmpty) return; - if (rememberAccount) { - bind.sessionPeerOption( - sessionId: sessionId, name: 'os-username', value: osUsername); - bind.sessionPeerOption( - sessionId: sessionId, name: 'os-password', value: osPassword); - } gFFI.login( osUsername, osPassword, @@ -1096,16 +1076,6 @@ _connectDialog( controller: osPasswordController, autoFocus: false, ), - if (canRememberAccount) - rememberWidget( - translate('remember_account_tip'), - rememberAccount, - (v) { - if (v != null) { - setState(() => rememberAccount = v); - } - }, - ), ], ); } @@ -1542,91 +1512,6 @@ showSetOSPassword( }); } -showSetOSAccount( - SessionID sessionId, - OverlayDialogManager dialogManager, -) async { - final usernameController = TextEditingController(); - final passwdController = TextEditingController(); - var username = - await bind.sessionGetOption(sessionId: sessionId, arg: 'os-username') ?? - ''; - var password = - await bind.sessionGetOption(sessionId: sessionId, arg: 'os-password') ?? - ''; - usernameController.text = username; - passwdController.text = password; - dialogManager.show((setState, close, context) { - submit() { - final username = usernameController.text.trim(); - final password = usernameController.text.trim(); - bind.sessionPeerOption( - sessionId: sessionId, name: 'os-username', value: username); - bind.sessionPeerOption( - sessionId: sessionId, name: 'os-password', value: password); - close(); - } - - descWidget(String text) { - return Column( - children: [ - Align( - alignment: Alignment.centerLeft, - child: Text( - text, - maxLines: 3, - softWrap: true, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 16), - ), - ), - Container( - height: 8, - ), - ], - ); - } - - return CustomAlertDialog( - title: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.password_rounded, color: MyTheme.accent), - Text(translate('OS Account')).paddingOnly(left: 10), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - descWidget(translate("os_account_desk_tip")), - DialogTextField( - title: translate(DialogTextField.kUsernameTitle), - controller: usernameController, - prefixIcon: DialogTextField.kUsernameIcon, - errorText: null, - ), - PasswordWidget(controller: passwdController), - ], - ), - actions: [ - dialogButton( - "Cancel", - icon: Icon(Icons.close_rounded), - onPressed: close, - isOutline: true, - ), - dialogButton( - "OK", - icon: Icon(Icons.done_rounded), - onPressed: submit, - ), - ], - onSubmit: submit, - onCancel: close, - ); - }); -} - Widget buildNoteTextField({ required TextEditingController controller, required VoidCallback onEscape, diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 0e4c5b7a5..c3896b097 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -349,12 +349,12 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { showRequestElevationDialog(sessionId, ffi.dialogManager)), ); } - // osAccount / osPassword + // osPassword if (isDefaultConn && perms['keyboard'] != false) { v.add( TTextMenu( child: Row(children: [ - Text(translate(pi.isHeadless ? 'OS Account' : 'OS Password')), + Text(translate('OS Password')), ]), trailingIcon: Transform.scale( scale: (isDesktop || isWebDesktop) ? 0.8 : 1, @@ -363,18 +363,12 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { if (isMobile && Navigator.canPop(context)) { Navigator.pop(context); } - if (pi.isHeadless) { - showSetOSAccount(sessionId, ffi.dialogManager); - } else { - handleOsPasswordEditIcon(sessionId, ffi.dialogManager); - } + handleOsPasswordEditIcon(sessionId, ffi.dialogManager); }, icon: Icon(Icons.edit, color: isMobile ? MyTheme.accent : null), ), ), - onPressed: () => pi.isHeadless - ? showSetOSAccount(sessionId, ffi.dialogManager) - : handleOsPasswordAction(sessionId, ffi.dialogManager), + onPressed: () => handleOsPasswordAction(sessionId, ffi.dialogManager), ), ); } diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 6c22057f9..9eb21665a 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -18,7 +18,6 @@ const kKeyMapMode = 'map'; const kKeyTranslateMode = 'translate'; const String kPlatformAdditionsIsWayland = "is_wayland"; -const String kPlatformAdditionsHeadless = "headless"; const String kPlatformAdditionsIsInstalled = "is_installed"; const String kPlatformAdditionsIddImpl = "idd_impl"; const String kPlatformAdditionsRustDeskVirtualDisplays = @@ -164,7 +163,6 @@ const String kOptionEnableConfirmClosingTabs = "enable-confirm-closing-tabs"; const String kOptionAllowAlwaysSoftwareRender = "allow-always-software-render"; const String kOptionEnableCheckUpdate = "enable-check-update"; const String kOptionAllowAutoUpdate = "allow-auto-update"; -const String kOptionAllowLinuxHeadless = "allow-linux-headless"; const String kOptionAllowRemoveWallpaper = "allow-remove-wallpaper"; const String kOptionStopService = "stop-service"; const String kOptionDirectxCapture = "enable-directx-capture"; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index a67facfa9..c696ad510 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -586,10 +586,6 @@ class _GeneralState extends State<_General> { )); } - if (!isWeb && bind.mainShowOption(key: kOptionAllowLinuxHeadless)) { - children.add(_OptionCheckBox( - context, 'Allow linux headless', kOptionAllowLinuxHeadless)); - } if (!bind.isDisableAccount()) { children.add(_OptionCheckBox( context, diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 7bab906bc..74f72021b 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -908,17 +908,12 @@ class FfiModel with ChangeNotifier { enter2FaDialog(sessionId, dialogManager); } else if (type == 'input-password') { enterPasswordDialog(sessionId, dialogManager); - } else if (type == 'session-login' || type == 'session-re-login') { - enterUserLoginDialog(sessionId, dialogManager, 'login_linux_tip', true); - } else if (type == 'session-login-password') { - enterUserLoginAndPasswordDialog( - sessionId, dialogManager, 'login_linux_tip', true); } else if (type == 'terminal-admin-login') { enterUserLoginDialog( - sessionId, dialogManager, 'terminal-admin-login-tip', false); + sessionId, dialogManager, 'terminal-admin-login-tip'); } else if (type == 'terminal-admin-login-password') { enterUserLoginAndPasswordDialog( - sessionId, dialogManager, 'terminal-admin-login-tip', false); + sessionId, dialogManager, 'terminal-admin-login-tip'); } else if (type == 'restarting') { // Treat restart messages as reconnect control events. Rust still sends // title/text for legacy UI and translation reuse; Flutter keeps the last @@ -4168,7 +4163,6 @@ class PeerInfo with ChangeNotifier { RxBool isSet = false.obs; bool get isWayland => platformAdditions[kPlatformAdditionsIsWayland] == true; - bool get isHeadless => platformAdditions[kPlatformAdditionsHeadless] == true; bool get isInstalled => platform != kPeerPlatformWindows || platformAdditions[kPlatformAdditionsIsInstalled] == true; diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index b59c769da..f4a082941 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -762,10 +762,6 @@ class RustdeskImpl { throw UnimplementedError("mainGetError"); } - bool mainShowOption({required String key, dynamic hint}) { - throw UnimplementedError("mainShowOption"); - } - Future mainSetOption( {required String key, required String value, dynamic hint}) { js.context.callMethod('setByName', [ diff --git a/libs/hbb_common b/libs/hbb_common index f124c0a5d..b2b1ac453 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit f124c0a5d49a4a13381902124b65364ff28fa541 +Subproject commit b2b1ac453d1d694046f63be20d792d608dac1c93 diff --git a/res/PKGBUILD b/res/PKGBUILD index 8f3cc9c81..b3b64f311 100644 --- a/res/PKGBUILD +++ b/res/PKGBUILD @@ -7,7 +7,7 @@ arch=('x86_64') url="" license=('AGPL-3.0') groups=() -depends=('gtk3' 'xdotool' 'libxcb' 'libxfixes' 'alsa-lib' 'libva' 'libappindicator-gtk3' 'pam' 'gst-plugins-base' 'gst-plugin-pipewire') +depends=('gtk3' 'xdotool' 'libxcb' 'libxfixes' 'alsa-lib' 'libva' 'libappindicator-gtk3' 'gst-plugins-base' 'gst-plugin-pipewire') makedepends=() checkdepends=() optdepends=() diff --git a/res/pam.d/rustdesk.debian b/res/pam.d/rustdesk.debian deleted file mode 100644 index 789ce8f7c..000000000 --- a/res/pam.d/rustdesk.debian +++ /dev/null @@ -1,5 +0,0 @@ -#%PAM-1.0 -@include common-auth -@include common-account -@include common-session -@include common-password diff --git a/res/pam.d/rustdesk.suse b/res/pam.d/rustdesk.suse deleted file mode 100644 index a7c7836ce..000000000 --- a/res/pam.d/rustdesk.suse +++ /dev/null @@ -1,5 +0,0 @@ -#%PAM-1.0 -auth include common-auth -account include common-account -session include common-session -password include common-password diff --git a/res/rpm-flutter-suse.spec b/res/rpm-flutter-suse.spec index ea7dd8a40..5b8a0d416 100644 --- a/res/rpm-flutter-suse.spec +++ b/res/rpm-flutter-suse.spec @@ -5,7 +5,7 @@ Summary: RPM package License: GPL-3.0 URL: https://rustdesk.com Vendor: rustdesk -Requires: gtk3 libxcb1 libXfixes3 alsa-utils libXtst6 libva2 pam gstreamer-plugins-base gstreamer-plugin-pipewire +Requires: gtk3 libxcb1 libXfixes3 alsa-utils libXtst6 libva2 gstreamer-plugins-base gstreamer-plugin-pipewire Recommends: libayatana-appindicator3-1 xdotool Provides: libdesktop_drop_plugin.so()(64bit), libdesktop_multi_window_plugin.so()(64bit), libfile_selector_linux_plugin.so()(64bit), libflutter_custom_cursor_plugin.so()(64bit), libflutter_linux_gtk.so()(64bit), libscreen_retriever_plugin.so()(64bit), libtray_manager_plugin.so()(64bit), liburl_launcher_linux_plugin.so()(64bit), libwindow_manager_plugin.so()(64bit), libwindow_size_plugin.so()(64bit), libtexture_rgba_renderer_plugin.so()(64bit) diff --git a/res/rpm-flutter.spec b/res/rpm-flutter.spec index 272148d91..70fef6325 100644 --- a/res/rpm-flutter.spec +++ b/res/rpm-flutter.spec @@ -5,7 +5,7 @@ Summary: RPM package License: GPL-3.0 URL: https://rustdesk.com Vendor: rustdesk -Requires: gtk3 libxcb libXfixes alsa-lib libva pam gstreamer1-plugins-base +Requires: gtk3 libxcb libXfixes alsa-lib libva gstreamer1-plugins-base Recommends: libayatana-appindicator-gtk3 libxdo Provides: libdesktop_drop_plugin.so()(64bit), libdesktop_multi_window_plugin.so()(64bit), libfile_selector_linux_plugin.so()(64bit), libflutter_custom_cursor_plugin.so()(64bit), libflutter_linux_gtk.so()(64bit), libscreen_retriever_plugin.so()(64bit), libtray_manager_plugin.so()(64bit), liburl_launcher_linux_plugin.so()(64bit), libwindow_manager_plugin.so()(64bit), libwindow_size_plugin.so()(64bit), libtexture_rgba_renderer_plugin.so()(64bit) diff --git a/res/rpm-suse.spec b/res/rpm-suse.spec index 14364eb77..b2f64d5b1 100644 --- a/res/rpm-suse.spec +++ b/res/rpm-suse.spec @@ -3,7 +3,7 @@ Version: 1.1.9 Release: 0 Summary: RPM package License: GPL-3.0 -Requires: gtk3 libxcb1 libXfixes3 alsa-utils libXtst6 libva2 pam gstreamer-plugins-base gstreamer-plugin-pipewire +Requires: gtk3 libxcb1 libXfixes3 alsa-utils libXtst6 libva2 gstreamer-plugins-base gstreamer-plugin-pipewire Recommends: libayatana-appindicator3-1 xdotool # https://docs.fedoraproject.org/en-US/packaging-guidelines/Scriptlets/ diff --git a/res/rpm.spec b/res/rpm.spec index 8aaf2508c..18eb46c75 100644 --- a/res/rpm.spec +++ b/res/rpm.spec @@ -5,7 +5,7 @@ Summary: RPM package License: GPL-3.0 URL: https://rustdesk.com Vendor: rustdesk -Requires: gtk3 libxcb libXfixes alsa-lib libva2 pam gstreamer1-plugins-base +Requires: gtk3 libxcb libXfixes alsa-lib libva2 gstreamer1-plugins-base Recommends: libayatana-appindicator-gtk3 libxdo # https://docs.fedoraproject.org/en-US/packaging-guidelines/Scriptlets/ diff --git a/res/startwm.sh b/res/startwm.sh deleted file mode 100755 index 04e7a5a18..000000000 --- a/res/startwm.sh +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env bash - -# This script is derived from https://github.com/neutrinolabs/xrdp/sesman/startwm.sh. - -# -# This script is an example. You might need to edit this script -# depending on your distro if it doesn't work for you. -# -# Uncomment the following line for debug: -# exec xterm - - -# Execution sequence for interactive login shell - pseudocode -# -# IF /etc/profile is readable THEN -# execute ~/.bash_profile -# END IF -# IF ~/.bash_profile is readable THEN -# execute ~/.bash_profile -# ELSE -# IF ~/.bash_login is readable THEN -# execute ~/.bash_login -# ELSE -# IF ~/.profile is readable THEN -# execute ~/.profile -# END IF -# END IF -# END IF -pre_start() -{ - if [ -r /etc/profile ]; then - . /etc/profile - fi - if [ -r ~/.bash_profile ]; then - . ~/.bash_profile - else - if [ -r ~/.bash_login ]; then - . ~/.bash_login - else - if [ -r ~/.profile ]; then - . ~/.profile - fi - fi - fi - return 0 -} - -# When logging out from the interactive shell, the execution sequence is: -# -# IF ~/.bash_logout exists THEN -# execute ~/.bash_logout -# END IF -post_start() -{ - if [ -r ~/.bash_logout ]; then - . ~/.bash_logout - fi - return 0 -} - -#start the window manager -wm_start() -{ - if [ -r /etc/default/locale ]; then - . /etc/default/locale - export LANG LANGUAGE - fi - - # debian - if [ -r /etc/X11/Xsession ]; then - pre_start - . /etc/X11/Xsession - post_start - exit 0 - fi - - # alpine - # Don't use /etc/X11/xinit/Xsession - it doesn't work - if [ -f /etc/alpine-release ]; then - if [ -f /etc/X11/xinit/xinitrc ]; then - pre_start - /etc/X11/xinit/xinitrc - post_start - else - echo "** xinit package isn't installed" >&2 - exit 1 - fi - fi - - # el - if [ -r /etc/X11/xinit/Xsession ]; then - pre_start - . /etc/X11/xinit/Xsession - post_start - exit 0 - fi - - # suse - if [ -r /etc/X11/xdm/Xsession ]; then - # since the following script run a user login shell, - # do not execute the pseudo login shell scripts - . /etc/X11/xdm/Xsession - exit 0 - elif [ -r /usr/etc/X11/xdm/Xsession ]; then - . /usr/etc/X11/xdm/Xsession - exit 0 - fi - - pre_start - xterm - post_start -} - -#. /etc/environment -#export PATH=$PATH -#export LANG=$LANG - -# change PATH to be what your environment needs usually what is in -# /etc/environment -#PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games" -#export PATH=$PATH - -# for PATH and LANG from /etc/environment -# pam will auto process the environment file if /etc/pam.d/xrdp-sesman -# includes -# auth required pam_env.so readenv=1 - -wm_start - -exit 1 diff --git a/res/xorg.conf b/res/xorg.conf deleted file mode 100644 index fe1539995..000000000 --- a/res/xorg.conf +++ /dev/null @@ -1,30 +0,0 @@ -Section "Monitor" - Identifier "Dummy Monitor" - - # Default HorizSync 31.50 - 48.00 kHz - HorizSync 5.0 - 150.0 - # Default VertRefresh 50.00 - 70.00 Hz - VertRefresh 5.0 - 100.0 - - # Taken from https://www.xpra.org/xorg.conf - Modeline "1920x1080" 23.53 1920 1952 2040 2072 1080 1106 1108 1135 - Modeline "1280x720" 27.41 1280 1312 1416 1448 720 737 740 757 -EndSection - -Section "Device" - Identifier "Dummy VideoCard" - Driver "dummy" - # Default VideoRam 4096 - # (1920 * 1080 * 4) / 1024 = 8100 - VideoRam 8100 -EndSection - -Section "Screen" - Identifier "Dummy Screen" - Device "Dummy VideoCard" - Monitor "Dummy Monitor" - SubSection "Display" - Depth 24 - Modes "1920x1080" "1280x720" - EndSubSection -EndSection \ No newline at end of file diff --git a/src/client.rs b/src/client.rs index e1e4c8034..73cf466eb 100644 --- a/src/client.rs +++ b/src/client.rs @@ -101,18 +101,6 @@ const RESTART_REMOTE_DEVICE_GRACE: Duration = Duration::from_secs(5 * 60); pub const VIDEO_QUEUE_SIZE: usize = 120; const MAX_DECODE_FAIL_COUNTER: usize = 3; -#[cfg(target_os = "linux")] -pub const LOGIN_MSG_DESKTOP_NOT_INITED: &str = "Desktop env is not inited"; -pub const LOGIN_MSG_DESKTOP_SESSION_NOT_READY: &str = "Desktop session not ready"; -pub const LOGIN_MSG_DESKTOP_XSESSION_FAILED: &str = "Desktop xsession failed"; -pub const LOGIN_MSG_DESKTOP_SESSION_ANOTHER_USER: &str = "Desktop session another user login"; -pub const LOGIN_MSG_DESKTOP_XORG_NOT_FOUND: &str = "Desktop xorg not found"; -// ls /usr/share/xsessions/ -pub const LOGIN_MSG_DESKTOP_NO_DESKTOP: &str = "Desktop none"; -pub const LOGIN_MSG_DESKTOP_SESSION_NOT_READY_PASSWORD_EMPTY: &str = - "Desktop session not ready, password empty"; -pub const LOGIN_MSG_DESKTOP_SESSION_NOT_READY_PASSWORD_WRONG: &str = - "Desktop session not ready, password wrong"; pub const LOGIN_MSG_PASSWORD_EMPTY: &str = "Empty Password"; pub const LOGIN_MSG_PASSWORD_WRONG: &str = "Wrong Password"; pub const LOGIN_MSG_2FA_WRONG: &str = "Wrong 2FA Code"; @@ -2739,6 +2727,16 @@ impl LoginConfigHandler { } else { Bytes::new() }; + let os_login: MessageField = if self.conn_type == ConnType::TERMINAL { + Some(OSLogin { + username: os_username, + password: os_password, + ..Default::default() + }) + .into() + } else { + Default::default() + }; let mut lr = LoginRequest { username: pure_id, password: password.into(), @@ -2748,12 +2746,7 @@ impl LoginConfigHandler { option: self.get_option_message(true).into(), session_id: self.session_id, version: crate::VERSION.to_string(), - os_login: Some(OSLogin { - username: os_username, - password: os_password, - ..Default::default() - }) - .into(), + os_login, hwid, avatar, ..Default::default() @@ -3348,55 +3341,12 @@ struct LoginErrorMsgBox { lazy_static::lazy_static! { static ref LOGIN_ERROR_MAP: Arc> = { - use config::LINK_HEADLESS_LINUX_SUPPORT; let map = HashMap::from([(LOGIN_SCREEN_WAYLAND, LoginErrorMsgBox{ msgtype: "error", title: "Login Error", text: "Login screen using Wayland is not supported", link: "https://rustdesk.com/docs/en/manual/linux/#login-screen", try_again: true, - }), (LOGIN_MSG_DESKTOP_SESSION_NOT_READY, LoginErrorMsgBox{ - msgtype: "session-login", - title: "", - text: "", - link: "", - try_again: true, - }), (LOGIN_MSG_DESKTOP_XSESSION_FAILED, LoginErrorMsgBox{ - msgtype: "session-re-login", - title: "", - text: "", - link: "", - try_again: true, - }), (LOGIN_MSG_DESKTOP_SESSION_ANOTHER_USER, LoginErrorMsgBox{ - msgtype: "info-nocancel", - title: "another_user_login_title_tip", - text: "another_user_login_text_tip", - link: "", - try_again: false, - }), (LOGIN_MSG_DESKTOP_XORG_NOT_FOUND, LoginErrorMsgBox{ - msgtype: "info-nocancel", - title: "xorg_not_found_title_tip", - text: "xorg_not_found_text_tip", - link: LINK_HEADLESS_LINUX_SUPPORT, - try_again: true, - }), (LOGIN_MSG_DESKTOP_NO_DESKTOP, LoginErrorMsgBox{ - msgtype: "info-nocancel", - title: "no_desktop_title_tip", - text: "no_desktop_text_tip", - link: LINK_HEADLESS_LINUX_SUPPORT, - try_again: true, - }), (LOGIN_MSG_DESKTOP_SESSION_NOT_READY_PASSWORD_EMPTY, LoginErrorMsgBox{ - msgtype: "session-login-password", - title: "", - text: "", - link: "", - try_again: true, - }), (LOGIN_MSG_DESKTOP_SESSION_NOT_READY_PASSWORD_WRONG, LoginErrorMsgBox{ - msgtype: "session-login-re-password", - title: "", - text: "", - link: "", - try_again: true, }), (LOGIN_MSG_NO_PASSWORD_ACCESS, LoginErrorMsgBox{ msgtype: "wait-remote-accept-nook", title: "Prompt", @@ -3665,17 +3615,7 @@ pub async fn handle_hash( hasher.finalize()[..].into() }; - let is_terminal = lc.read().unwrap().conn_type.eq(&ConnType::TERMINAL); - let (os_username, os_password) = if is_terminal { - ("".to_owned(), "".to_owned()) - } else { - ( - lc.read().unwrap().get_option("os-username"), - lc.read().unwrap().get_option("os-password"), - ) - }; - - send_login(lc.clone(), os_username, os_password, password, peer).await; + send_login(lc.clone(), String::new(), String::new(), password, peer).await; lc.write().unwrap().hash = hash; true } diff --git a/src/common.rs b/src/common.rs index 09fa1b4ca..9b22cbca2 100644 --- a/src/common.rs +++ b/src/common.rs @@ -105,7 +105,7 @@ lazy_static::lazy_static! { // Is server logic running. The server code can invoked to run by the main process if --server is not running. static ref SERVER_RUNNING: Arc> = Default::default(); static ref IS_MAIN: bool = std::env::args().nth(1).map_or(true, |arg| !arg.starts_with("--")); - static ref IS_CM: bool = std::env::args().nth(1) == Some("--cm".to_owned()) || std::env::args().nth(1) == Some("--cm-no-ui".to_owned()); + static ref IS_CM: bool = std::env::args().nth(1) == Some("--cm".to_owned()); } pub struct SimpleCallOnReturn { diff --git a/src/core_main.rs b/src/core_main.rs index 3a190f114..9b3d76f0a 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -713,14 +713,6 @@ pub fn core_main() -> Option> { // call connection manager to establish connections // meanwhile, return true to call flutter window to show control panel crate::ui_interface::start_option_status_sync(); - } else if args[0] == "--cm-no-ui" { - #[cfg(feature = "flutter")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - crate::ui_interface::start_option_status_sync(); - crate::flutter::connection_manager::start_cm_no_ui(); - } - return None; } else if args[0] == "--whiteboard" { #[cfg(not(any(target_os = "android", target_os = "ios")))] { diff --git a/src/flutter.rs b/src/flutter.rs index 87c9c02af..1ed44ee49 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -1552,20 +1552,8 @@ pub mod connection_manager { } } - #[inline] #[cfg(not(any(target_os = "android", target_os = "ios")))] - pub fn start_cm_no_ui() { - start_listen_ipc(false); - } - - #[inline] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - fn start_listen_ipc_thread() { - start_listen_ipc(true); - } - - #[cfg(not(any(target_os = "android", target_os = "ios")))] - fn start_listen_ipc(new_thread: bool) { + fn start_listen_ipc() { use crate::ui_cm_interface::{start_ipc, ConnectionManager}; #[cfg(target_os = "linux")] @@ -1574,17 +1562,13 @@ pub mod connection_manager { let cm = ConnectionManager { ui_handler: FlutterHandler {}, }; - if new_thread { - std::thread::spawn(move || start_ipc(cm)); - } else { - start_ipc(cm); - } + std::thread::spawn(move || start_ipc(cm)); } #[inline] pub fn cm_init() { #[cfg(not(any(target_os = "android", target_os = "ios")))] - start_listen_ipc_thread(); + start_listen_ipc(); } #[cfg(target_os = "android")] diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 091fcef25..f840ed282 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -962,14 +962,6 @@ pub fn main_get_error() -> String { get_error() } -pub fn main_show_option(_key: String) -> SyncReturn { - #[cfg(target_os = "linux")] - if _key.eq(config::keys::OPTION_ALLOW_LINUX_HEADLESS) { - return SyncReturn(true); - } - SyncReturn(false) -} - pub fn main_set_option(key: String, value: String) { #[cfg(target_os = "android")] { diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 61044da3e..04d982ba9 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "هذا الملف مطابق لملف موجود عن القرين."), ("show_monitors_tip", "عرض الشاشات في شريط الادوات"), ("View Mode", "وضع العرض"), - ("login_linux_tip", "تحتاج الى تسجيل الدخول حساب لينكس البعيد وتفعيل جلسة سطح مكتب X"), ("verify_rustdesk_password_tip", "تحقق من كلمة مرور RustDesk"), - ("remember_account_tip", "تذكر هذا الحساب"), - ("os_account_desk_tip", "هذا الحساب مستخدم لتسجيل الدخول الى سطح المكتب البعيد وتفعيل الجلسة"), - ("OS Account", "حساب نظام التشغيل"), - ("another_user_login_title_tip", "مستخدم اخر مسجل دخول حاليا"), - ("another_user_login_text_tip", "قطع الاتصال"), - ("xorg_not_found_title_tip", "Xorg غير موجود"), - ("xorg_not_found_text_tip", "الرجاء تثبيت Xorg"), - ("no_desktop_title_tip", "لا يتوفر سطح مكتب"), - ("no_desktop_text_tip", "الرجاء تثبيت سطح مكتب GNOME"), ("No need to elevate", "لا حاجة للارتقاء"), ("System Sound", "صوت النظام"), ("Default", "الافتراضي"), diff --git a/src/lang/be.rs b/src/lang/be.rs index 1159dac84..6d2c93882 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Файл ідэнтычны файлу абанента"), ("show_monitors_tip", "Паказваць маніторы на панэлі інструментаў"), ("View Mode", "Рэжым прагляду"), - ("login_linux_tip", "Каб уключыць сеанс працоўнага стала X, трэба ўвайсці ў аддалены ўліковы запіс Linux."), ("verify_rustdesk_password_tip", "Пацвердзіць пароль RustDesk"), - ("remember_account_tip", "Запомніць гэты ўліковы запіс"), - ("os_account_desk_tip", "Гэты ўліковы запіс выкарыстоўваецца для ўваходу ў аддаленую аперацыйную сістэму і ўключэння сеанса працоўнага стала ў рэжыме headless."), - ("OS Account", "Акаўнт АС"), - ("another_user_login_title_tip", "Іншы карыстальнік ужо ўвайшоў у сістэму"), - ("another_user_login_text_tip", "Адключыць"), - ("xorg_not_found_title_tip", "Xorg не знойдзены"), - ("xorg_not_found_text_tip", "Усталюйце Xorg"), - ("no_desktop_title_tip", "Няма даступных працоўных сталоў"), - ("no_desktop_text_tip", "Усталюйце GNOME Desktop"), ("No need to elevate", "Павышэнне правоў не патрабуецца"), ("System Sound", "Сістэмны гук"), ("Default", "Стандартна"), diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 568ec085e..83c98545e 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Файлът съвпада с този от другата страна."), ("show_monitors_tip", "Показване на мониторите в лентата с инструменти"), ("View Mode", "Режим на изглед"), - ("login_linux_tip", "Трябва да влезете в отдалечен Linux акаунт, за да активирате X сесия на работния плот"), ("verify_rustdesk_password_tip", "Проверете RustDesk паролата"), - ("remember_account_tip", "Запомнете този акаунт"), - ("os_account_desk_tip", "Този акаунт се използва за влизане в отдалечената операционна система и позволява на десктоп сесия без моинитор"), - ("OS Account", "Профил в операционната система"), - ("another_user_login_title_tip", "Друг потребител вече е влязъл"), - ("another_user_login_text_tip", "Прекъснете връзката"), - ("xorg_not_found_title_tip", "Xorg не е намерен"), - ("xorg_not_found_text_tip", "Моля, инсталирайте Xorg"), - ("no_desktop_title_tip", "Няма наличен работен плот"), - ("no_desktop_text_tip", "Моля, инсталирайте работен плот GNOME"), ("No need to elevate", "Няма нужда за повишаване на права"), ("System Sound", "Системен звук"), ("Default", "По подразбиране"), diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 3bc9c6375..9b0ebb085 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Aquest fitxer és idèntic al del client."), ("show_monitors_tip", "Mostra les pantalles a la barra d'eines"), ("View Mode", "Mode espectador"), - ("login_linux_tip", "És necessari que inicieu prèviament sessió amb un entorn d'escriptori x11 habilitat"), ("verify_rustdesk_password_tip", "Verifica la contrasenya del RustDesk"), - ("remember_account_tip", "Recorda aquest compte"), - ("os_account_desk_tip", "S'utilitza aquest compte per iniciar la sessió al sistema remot i habilitar el mode sense cap pantalla connectada"), - ("OS Account", "Compte d'usuari"), - ("another_user_login_title_tip", "Altre usuari ha iniciat ja una sessió"), - ("another_user_login_text_tip", "Desconnecta"), - ("xorg_not_found_title_tip", "No s'ha trobat l'entorn Xorg"), - ("xorg_not_found_text_tip", "Instal·leu el Xorg"), - ("no_desktop_title_tip", "Cap escriptori disponible"), - ("no_desktop_text_tip", "Instal·leu l'entorn d'escriptori GNOME"), ("No need to elevate", "No calen permisos ampliats"), ("System Sound", "So del sistema"), ("Default", "per defecte"), diff --git a/src/lang/cn.rs b/src/lang/cn.rs index b64824731..191c25908 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "此文件与对方的一致"), ("show_monitors_tip", "在工具栏上显示监视器"), ("View Mode", "浏览模式"), - ("login_linux_tip", "登录被控端的 Linux 账户,才能启用 X 桌面"), ("verify_rustdesk_password_tip", "验证 RustDesk 密码"), - ("remember_account_tip", "记住此账户"), - ("os_account_desk_tip", "在无显示器的环境下,此账户用于登录被控系统,并启用桌面"), - ("OS Account", "系统账户"), - ("another_user_login_title_tip", "其他用户已登录"), - ("another_user_login_text_tip", "断开"), - ("xorg_not_found_title_tip", "Xorg 未安装"), - ("xorg_not_found_text_tip", "请安装 Xorg"), - ("no_desktop_title_tip", "desktop 未安装"), - ("no_desktop_text_tip", "请安装 desktop"), ("No need to elevate", "无需提升权限"), ("System Sound", "系统音频"), ("Default", "默认"), diff --git a/src/lang/cs.rs b/src/lang/cs.rs index fd32948ad..1d214e024 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Tento soubor je totožný se souborem partnera."), ("show_monitors_tip", "Zobrazit monitory na panelu nástrojů"), ("View Mode", "Režim zobrazení"), - ("login_linux_tip", "Chcete-li povolit relaci plochy X, musíte se přihlásit ke vzdálenému účtu systému Linux."), ("verify_rustdesk_password_tip", "Ověření hesla RustDesk"), - ("remember_account_tip", "Zapamatovat si tento účet"), - ("os_account_desk_tip", "Tento účet se používá k přihlášení do vzdáleného operačního systému a k povolení relace plochy v režimu headless."), - ("OS Account", "Účet operačního systému"), - ("another_user_login_title_tip", "Další uživatel je již přihlášen"), - ("another_user_login_text_tip", "Odpojit"), - ("xorg_not_found_title_tip", "Xorg nebyl nalezen"), - ("xorg_not_found_text_tip", "Prosím, nainstalujte Xorg"), - ("no_desktop_title_tip", "Není k dispozici žádná plocha"), - ("no_desktop_text_tip", "Nainstalujte si prosím prostředí GNOME"), ("No need to elevate", "Není třeba navýšení"), ("System Sound", "Systémový zvuk"), ("Default", "Výchozí"), diff --git a/src/lang/da.rs b/src/lang/da.rs index 3edbb9d2d..4cf509e98 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Denne fil er identisk med modpartens."), ("show_monitors_tip", "Vis skærme i værktøjsbjælken"), ("View Mode", "Visningstilstand"), - ("login_linux_tip", "Du skal logge på en fjernstyret Linux konto for at aktivere en X skrivebordssession"), ("verify_rustdesk_password_tip", "Bekræft RustDesk adgangskode"), - ("remember_account_tip", "Husk denne konto"), - ("os_account_desk_tip", "Denne konto benyttes til at logge på fjernsystemet, og aktivere skrivebordssessionen i hovedløs tilstand"), - ("OS Account", "Styresystem konto"), - ("another_user_login_title_tip", "En anden bruger er allerede logget ind"), - ("another_user_login_text_tip", "Frakobl"), - ("xorg_not_found_title_tip", "Xorg ikke fundet"), - ("xorg_not_found_text_tip", "Installér venlist Xorg"), - ("no_desktop_title_tip", "Intet skrivebordsmiljø er tilgængeligt"), - ("no_desktop_text_tip", "Installér venligst GNOME skrivebordet"), ("No need to elevate", "Ingen grund til at elevere"), ("System Sound", "Systemlyd"), ("Default", "Standard"), diff --git a/src/lang/de.rs b/src/lang/de.rs index dcddb94fa..833be3fca 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Diese Datei ist identisch mit der Datei der Gegenstelle."), ("show_monitors_tip", "Bildschirme in der Symbolleiste anzeigen"), ("View Mode", "Ansichtsmodus"), - ("login_linux_tip", "Sie müssen sich an einem entfernten Linux-Konto anmelden, um eine X-Desktop-Sitzung zu eröffnen."), ("verify_rustdesk_password_tip", "RustDesk-Passwort bestätigen"), - ("remember_account_tip", "Dieses Konto merken"), - ("os_account_desk_tip", "Dieses Konto wird verwendet, um sich beim entfernten Betriebssystem anzumelden und die Desktop-Sitzung im Headless-Modus zu aktivieren."), - ("OS Account", "Betriebssystem-Konto"), - ("another_user_login_title_tip", "Ein anderer Benutzer ist bereits angemeldet."), - ("another_user_login_text_tip", "Trennen"), - ("xorg_not_found_title_tip", "Xorg nicht gefunden."), - ("xorg_not_found_text_tip", "Bitte installieren Sie Xorg."), - ("no_desktop_title_tip", "Es ist keine Desktopumgebung verfügbar."), - ("no_desktop_text_tip", "Bitte installieren Sie den GNOME-Desktop."), ("No need to elevate", "Erhöhung der Rechte nicht erforderlich"), ("System Sound", "Systemsound"), ("Default", "Systemstandard"), diff --git a/src/lang/el.rs b/src/lang/el.rs index 28088a6bd..cc7591ea3 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Αυτό το αρχείο είναι πανομοιότυπο με αυτό του απομακρυσμένου σταθμού."), ("show_monitors_tip", "Εμφάνιση οθονών στη γραμμή εργαλείων"), ("View Mode", "Λειτουργία προβολής"), - ("login_linux_tip", "Πρέπει να συνδεθείτε σε έναν απομακρυσμένο λογαριασμό Linux για να ενεργοποιήσετε μια συνεδρία επιφάνειας εργασίας X"), ("verify_rustdesk_password_tip", "Επιβεβαιώστε τον κωδικό του RustDesk"), - ("remember_account_tip", "Απομνημόνευση αυτού του λογαριασμού"), - ("os_account_desk_tip", "Αυτός ο λογαριασμός χρησιμοποιείται για σύνδεση στο απομακρυσμένο λειτουργικό σύστημα και ενεργοποίηση της συνεδρίας επιφάνειας εργασίας σε headless"), - ("OS Account", "Λογαριασμός λειτουργικού συστήματος"), - ("another_user_login_title_tip", "Υπάρχει ήδη άλλος συνδεδεμένος χρήστης"), - ("another_user_login_text_tip", "Αποσύνδεση"), - ("xorg_not_found_title_tip", "Δεν βρέθηκε το Xorg"), - ("xorg_not_found_text_tip", "Παρακαλώ εγκαταστήστε το Xorg"), - ("no_desktop_title_tip", "Δεν υπάρχει διαθέσιμο περιβάλλον επιφάνειας εργασίας"), - ("no_desktop_text_tip", "Παρακαλώ εγκαταστήστε το περιβάλλον GNOME"), ("No need to elevate", "Δεν χρειάζεται ανύψωση"), ("System Sound", "Ήχος συστήματος"), ("Default", "Προκαθορισμένο"), diff --git a/src/lang/en.rs b/src/lang/en.rs index fcd68a300..227a7e29e 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -155,17 +155,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "This file is identical with the peer's one."), ("show_monitors_tip", "Show monitors in toolbar"), ("View Mode", "View mode"), - ("login_linux_tip", "You need to login to remote Linux account to enable a X desktop session"), ("verify_rustdesk_password_tip", "Verify RustDesk password"), - ("remember_account_tip", "Remember this account"), - ("os_account_desk_tip", "This account is used to login the remote OS and enable the desktop session in headless"), - ("OS Account", "OS account"), - ("another_user_login_title_tip", "Another user already logged in"), - ("another_user_login_text_tip", "Disconnect"), - ("xorg_not_found_title_tip", "Xorg not found"), - ("xorg_not_found_text_tip", "Please install Xorg"), - ("no_desktop_title_tip", "No desktop environment is available"), - ("no_desktop_text_tip", "Please install GNOME desktop"), ("System Sound", "System sound"), ("Copy Fingerprint", "Copy fingerprint"), ("no fingerprints", "No fingerprints"), diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 8ff0c573e..48a49f96d 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Ĉi tiu dosiero estas identa kun tiu de la samulo."), ("show_monitors_tip", "Montri monitorojn en la ilobreto"), ("View Mode", "Rigarda reĝimo"), - ("login_linux_tip", "Vi devas ensaluti al la fora Linuksa konto por ebligi X-labortablan sesion"), ("verify_rustdesk_password_tip", "Kontroli RustDesk-pasvorton"), - ("remember_account_tip", "Memori ĉi tiun konton"), - ("os_account_desk_tip", "Ĉi tiu konto estas uzata por ensaluti al la fora operaciumo kaj ebligi la labortablan sesion en senekrana reĝimo"), - ("OS Account", "Konto de operaciumo"), - ("another_user_login_title_tip", "Alia uzanto jam ensalutis"), - ("another_user_login_text_tip", "Malkonekti"), - ("xorg_not_found_title_tip", "Xorg ne trovita"), - ("xorg_not_found_text_tip", "Bonvolu instali Xorg"), - ("no_desktop_title_tip", "Neniu labortabla medio disponeblas"), - ("no_desktop_text_tip", "Bonvolu instali GNOME-labortablon"), ("No need to elevate", "Ne necesas altigi"), ("System Sound", "Sistema sono"), ("Default", "Implicita"), diff --git a/src/lang/es.rs b/src/lang/es.rs index 43eefdbdb..b481fce7f 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Este archivo es idéntico al del par."), ("show_monitors_tip", "Mostrar monitores en la barra de herramientas"), ("View Mode", "Modo Vista"), - ("login_linux_tip", "Necesitas iniciar sesión con la cueneta del Linux remoto para activar una sesión de escritorio X"), ("verify_rustdesk_password_tip", "Verificar la contraseña de RustDesk"), - ("remember_account_tip", "Recordar esta cuenta"), - ("os_account_desk_tip", "Esta cueneta se usa para iniciar sesión en el sistema operativo remoto y habilitar la sesión de escritorio en headless."), - ("OS Account", "Cuenta del SO"), - ("another_user_login_title_tip", "Otro usuario ya ha iniciado sesión"), - ("another_user_login_text_tip", "Desconectar"), - ("xorg_not_found_title_tip", "Xorg no hallado"), - ("xorg_not_found_text_tip", "Por favor, instala Xorg"), - ("no_desktop_title_tip", "No hay escritorio disponible"), - ("no_desktop_text_tip", "Por favor, instala GNOME Desktop"), ("No need to elevate", "No es necesario elevar privilegios"), ("System Sound", "Sonido del Sistema"), ("Default", "Predeterminado"), diff --git a/src/lang/et.rs b/src/lang/et.rs index a4bbf543c..d916df419 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "See fail on partneri omaga identne."), ("show_monitors_tip", "Kuva kuvarid tööriistaribal"), ("View Mode", "Kuvarežiim"), - ("login_linux_tip", "X-töölaua seansi lubamiseks pead sisse logima Linuxi kaugkontosse."), ("verify_rustdesk_password_tip", "Kinnita RustDeski parool"), - ("remember_account_tip", "Jäta see konto meelde"), - ("os_account_desk_tip", "Seda kontot kasutatakse kaug-opsüsteemi sisselogimiseks ja töölaua seansi lubamiseks headless-režiimis."), - ("OS Account", "Opsüsteemi konto"), - ("another_user_login_title_tip", "Teine kasutaja on juba sisse logitud"), - ("another_user_login_text_tip", "Ühenda lahti"), - ("xorg_not_found_title_tip", "Xorg-i ei leitud"), - ("xorg_not_found_text_tip", "Palun paigalda Xorg"), - ("no_desktop_title_tip", "Töölaud pole saadaval"), - ("no_desktop_text_tip", "Palun paigalda GNOME Desktop"), ("No need to elevate", "Kõrgendamine pole vajalik"), ("System Sound", "Süsteemiheli"), ("Default", "Vaikimisi"), diff --git a/src/lang/eu.rs b/src/lang/eu.rs index 338c3dfbe..e74f3a285 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Fitxategi hau parekidearen berdina da."), ("show_monitors_tip", "Erakutsi monitoreak tresna-barran"), ("View Mode", "Ikuspen modua"), - ("login_linux_tip", "Urruneko Linux kontu batera hasi behar duzu saioa X mahaigain saio bat gaitzeko"), ("verify_rustdesk_password_tip", "Berretsi RustDesk pasahitza"), - ("remember_account_tip", "Gogoratu kontu hau"), - ("os_account_desk_tip", "Kontu hau bururik gabe urruneko SE hasi eta mahaigaineko saioa gaitzeko erabiltzen da"), - ("OS Account", "SE kontua"), - ("another_user_login_title_tip", "Beste erabiltzaile batek saioa hasi du dagoeneko"), - ("another_user_login_text_tip", "Deskonektatu"), - ("xorg_not_found_title_tip", "Ez da Xorg aurkitu"), - ("xorg_not_found_text_tip", "Mesedez, instalatu ezazu Xorg"), - ("no_desktop_title_tip", "Ez dago mahaigainik eskuragarri"), - ("no_desktop_text_tip", "Mesedez, instalatu ezazu GNOME Desktop"), ("No need to elevate", "Ez da beharrezkoa pribilegioen maila igotzea"), ("System Sound", "Sistemaren soinua"), ("Default", "Lehenetsia"), diff --git a/src/lang/fa.rs b/src/lang/fa.rs index f905d4b76..c9fd7b45e 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "این فایل با فایل همتا یکسان است."), ("show_monitors_tip", "نمایش مانیتورها در نوار ابزار"), ("View Mode", "حالت مشاهده"), - ("login_linux_tip", "برای فعال کردن دسکتاپ X، باید به حساب لینوکس راه دور وارد شوید"), ("verify_rustdesk_password_tip", "رمز عبور RustDesk را تأیید کنید"), - ("remember_account_tip", "این حساب را به خاطر بسپارید"), - ("os_account_desk_tip", "این حساب برای ورود به سیستم عامل راه دور و فعال کردن جلسه دسکتاپ در هدلس استفاده می شود"), - ("OS Account", "حساب کاربری سیستم عامل"), - ("another_user_login_title_tip", "کاربر دیگری قبلاً وارد شده است"), - ("another_user_login_text_tip", "قطع شدن"), - ("xorg_not_found_title_tip", "پیدا نشد Xorg"), - ("xorg_not_found_text_tip", "لطفا Xorg را نصب کنید"), - ("no_desktop_title_tip", "هیچ دسکتاپی در دسترس نیست"), - ("no_desktop_text_tip", "لطفا دسکتاپ گنوم را نصب کنید"), ("No need to elevate", "نیازی به ارتقاء نیست"), ("System Sound", "صدای سیستم"), ("Default", "پیش فرض"), diff --git a/src/lang/fi.rs b/src/lang/fi.rs index c9b6442e3..9cb8e8de1 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Saman niminen tiedosto on jo olemassa"), ("show_monitors_tip", "Näytä kaikki käytettävissä olevat näytöt"), ("View Mode", "Näkymätila"), - ("login_linux_tip", "Kirjaudu sisään Linux käyttäjätunnuksellasi"), ("verify_rustdesk_password_tip", "Vahvista RustDesk salasanasi kirjautumista varten"), - ("remember_account_tip", "Muista tilini kirjautumista varten"), - ("os_account_desk_tip", "Käytä käyttöjärjestelmän käyttäjätiliä kirjautumiseen"), - ("OS Account", "Käyttöjärjestelmän tili"), - ("another_user_login_title_tip", "Toinen käyttäjä on kirjautunut sisään"), - ("another_user_login_text_tip", "Etäistunto keskeytetään, koska toinen käyttäjä on ottanut hallinnan."), - ("xorg_not_found_title_tip", "Xorg ei löydy"), - ("xorg_not_found_text_tip", "X11 palvelinta ei löydetty. Vaihda Xorg ympäristöön jatkaaksesi."), - ("no_desktop_title_tip", "Työpöytää ei havaittu"), - ("no_desktop_text_tip", "Työpöytäympäristöä ei löydy. Asenna esimerkiksi GNOME tai XFCE."), ("No need to elevate", "Oikeuksien korotusta ei tarvita"), ("System Sound", "Järjestelmän ääni"), ("Default", "Oletus"), diff --git a/src/lang/fr.rs b/src/lang/fr.rs index cb78c5ff5..5b3204053 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Ce fichier est identique à celui sur l’appareil distant."), ("show_monitors_tip", "Afficher les écrans dans la barre d’outils"), ("View Mode", "Mode vue"), - ("login_linux_tip", "Vous devez vous connecter au compte Linux distant pour établir une session de bureau X"), ("verify_rustdesk_password_tip", "Vérifier le mot de passe RustDesk"), - ("remember_account_tip", "Se souvenir de ce compte"), - ("os_account_desk_tip", "Ce compte est utilisé pour se connecter au système d’exploitation distant et activer la session de bureau en mode sans affichage"), - ("OS Account", "Compte du système d’exploitation"), - ("another_user_login_title_tip", "Un autre utilisateur est déjà connecté"), - ("another_user_login_text_tip", "Déconnecter"), - ("xorg_not_found_title_tip", "Xorg introuvable"), - ("xorg_not_found_text_tip", "Veuillez installer Xorg"), - ("no_desktop_title_tip", "Aucun environnement de bureau n’est disponible"), - ("no_desktop_text_tip", "Veuillez installer l’environnement de bureau GNOME"), ("No need to elevate", "Élever les privilèges n’est pas nécessaire"), ("System Sound", "Son système"), ("Default", "Défaut"), diff --git a/src/lang/ge.rs b/src/lang/ge.rs index d1c76c69f..988570095 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "ფაილი იდენტურია დისტანციურ კვანძზე არსებული ფაილის"), ("show_monitors_tip", "მონიტორების ჩვენება ხელსაწყოთა პანელზე"), ("View Mode", "ნახვის რეჟიმი"), - ("login_linux_tip", "X სამუშაო მაგიდის სესიის ჩასართავად, საჭიროა დისტანციურ Linux ანგარიშში შესვლა."), ("verify_rustdesk_password_tip", "დაადასტურეთ RustDesk-ის პაროლი"), - ("remember_account_tip", "დაიმახსოვრეთ ეს ანგარიში"), - ("os_account_desk_tip", "ეს ანგარიში გამოიყენება დისტანციურ ოპერაციულ სისტემაში შესასვლელად და headless რეჟიმში სამუშაო მაგიდის სესიის ჩასართავად."), - ("OS Account", "ოპერაციული სისტემის ანგარიში"), - ("another_user_login_title_tip", "სხვა მომხმარებელი უკვე შესულია სისტემაში"), - ("another_user_login_text_tip", "გათიშვა"), - ("xorg_not_found_title_tip", "Xorg ვერ მოიძებნა"), - ("xorg_not_found_text_tip", "დააინსტალირეთ Xorg"), - ("no_desktop_title_tip", "სამუშაო მაგიდა არ არის ხელმისაწვდომი"), - ("no_desktop_text_tip", "დააინსტალირეთ GNOME Desktop"), ("No need to elevate", "უფლებების აწევა არ არის საჭირო"), ("System Sound", "სისტემური ხმა"), ("Default", "ნაგულისხმევი"), diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 8efa89e31..7825c5204 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "આ ફાઇલ પહેલેથી જ અસ્તિત્વમાં છે."), ("show_monitors_tip", "ટૂલબારમાં મોનિટર બતાવો"), ("View Mode", "વ્યુ મોડ"), - ("login_linux_tip", "રિમોટ Linux સત્ર માટે તમારે લોગિન કરવું પડશે"), ("verify_rustdesk_password_tip", "RustDesk પાસવર્ડ ચકાસો"), - ("remember_account_tip", "આ ખાતું યાદ રાખો"), - ("os_account_desk_tip", "એક્સેસ માટે OS ખાતાનો ઉપયોગ કરો"), - ("OS Account", "OS ખાતું"), - ("another_user_login_title_tip", "બીજો યુઝર પહેલેથી લોગિન છે"), - ("another_user_login_text_tip", "ડિસ્કનેક્ટ કરો અને ફરી પ્રયાસ કરો"), - ("xorg_not_found_title_tip", "Xorg મળ્યું નથી"), - ("xorg_not_found_text_tip", "કૃપા કરીને Xorg ઇન્સ્ટોલ કરો"), - ("no_desktop_title_tip", "કોઈ ડેસ્કટોપ ઉપલબ્ધ નથી"), - ("no_desktop_text_tip", "કૃપા કરીને Linux ડેસ્કટોપ ઇન્સ્ટોલ કરો"), ("No need to elevate", "એલિવેટ કરવાની જરૂર નથી"), ("System Sound", "સિસ્ટમ સાઉન્ડ"), ("Default", "ડિફોલ્ટ"), diff --git a/src/lang/he.rs b/src/lang/he.rs index 643359526..1183a3cbb 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "קובץ זה זהה לקובץ שבצד העמית."), ("show_monitors_tip", "הצג מסכים בסרגל כלים"), ("View Mode", "מצב תצוגה"), - ("login_linux_tip", "עליך להתחבר לחשבון Linux מרוחק כדי לאפשר פעילות שולחן עבודה X"), ("verify_rustdesk_password_tip", "אמת סיסמת RustDesk"), - ("remember_account_tip", "זכור חשבון זה"), - ("os_account_desk_tip", "חשבון זה משמש להתחברות למערכת ההפעלה המרוחקת ולהפעלת שולחן עבודה במצב לא מקוון"), - ("OS Account", "חשבון מערכת הפעלה"), - ("another_user_login_title_tip", "משתמש אחר כבר התחבר"), - ("another_user_login_text_tip", "נתק"), - ("xorg_not_found_title_tip", "Xorg לא נמצא"), - ("xorg_not_found_text_tip", "אנא התקן Xorg"), - ("no_desktop_title_tip", "אין שולחן עבודה זמין"), - ("no_desktop_text_tip", "אנא התקן שולחן עבודה GNOME"), ("No need to elevate", "אין צורך בהעלאת הרשאות"), ("System Sound", "צליל מערכת"), ("Default", "ברירת מחדל"), diff --git a/src/lang/hi.rs b/src/lang/hi.rs index 250a6c963..d73b381c0 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "यह फ़ाइल पहले से ही मौजूद है।"), ("show_monitors_tip", "टूलबार में मॉनिटर दिखाएं"), ("View Mode", "व्यू मोड"), - ("login_linux_tip", "रिमोट Linux सत्र शुरू करने के लिए आपको लॉगिन करना होगा"), ("verify_rustdesk_password_tip", "RustDesk पासवर्ड सत्यापित करें"), - ("remember_account_tip", "इस खाते को याद रखें"), - ("os_account_desk_tip", "रिमोट डेस्कटॉप को एक्सेस करने के लिए OS खाते का उपयोग करें"), - ("OS Account", "OS खाता"), - ("another_user_login_title_tip", "एक अन्य उपयोगकर्ता पहले से ही लॉगिन है"), - ("another_user_login_text_tip", "डिस्कनेक्ट करें और पुनः प्रयास करें"), - ("xorg_not_found_title_tip", "Xorg नहीं मिला"), - ("xorg_not_found_text_tip", "कृपया Xorg इंस्टॉल करें"), - ("no_desktop_title_tip", "कोई डेस्कटॉप उपलब्ध नहीं है"), - ("no_desktop_text_tip", "कृपया Linux डेस्कटॉप इंस्टॉल करें"), ("No need to elevate", "एलीवेट करने की आवश्यकता नहीं है"), ("System Sound", "सिस्टम साउंड"), ("Default", "डिफ़ॉल्ट"), diff --git a/src/lang/hr.rs b/src/lang/hr.rs index 46a559bc2..7a0f9d3cf 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Ova je datoteka identična partnerskoj datoteci."), ("show_monitors_tip", "Prikažite monitore na alatnoj traci"), ("View Mode", "Način prikaza"), - ("login_linux_tip", "Da biste omogućili sesiju X radne površine, morate se prijaviti na udaljeni Linux račun."), ("verify_rustdesk_password_tip", "Provjera lozinke za RustDesk"), - ("remember_account_tip", "Zapamti ovaj račun"), - ("os_account_desk_tip", "Ovaj se račun koristi za prijavu na udaljeni operativni sustav i za omogućavanje sesije radne površine u bezglavom načinu rada."), - ("OS Account", "Račun operativnog sustava"), - ("another_user_login_title_tip", "Drugi korisnik je već prijavljen"), - ("another_user_login_text_tip", "Prekini vezu"), - ("xorg_not_found_title_tip", "Xorg nije pronađen"), - ("xorg_not_found_text_tip", "Molimo instalirajte Xorg"), - ("no_desktop_title_tip", "Nema dostupne radne površine"), - ("no_desktop_text_tip", "Molimo instalirajte GNOME"), ("No need to elevate", "Nije potrebno povećanje"), ("System Sound", "Zvuk sustava"), ("Default", "Zadano"), diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 9e10eecb0..705dc867c 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Ez a fájl megegyezik a távoli állomás fájljával."), ("show_monitors_tip", "Képernyők megjelenítése az eszköztáron"), ("View Mode", "Nézet mód"), - ("login_linux_tip", "Az X-asztal munkamenet megnyitásához be kell jelentkeznie egy távoli Linux-fiókba."), ("verify_rustdesk_password_tip", "RustDesk jelszó megerősítése"), - ("remember_account_tip", "Emlékezzen erre a fiókra"), - ("os_account_desk_tip", "Ezzel a fiókkal bejelentkezhet a távoli operációs rendszerbe, és aktiválhatja az asztali munkamenetet fej nélküli módban."), - ("OS Account", "OS fiók"), - ("another_user_login_title_tip", "Egy másik felhasználó már bejelentkezett."), - ("another_user_login_text_tip", "Különálló"), - ("xorg_not_found_title_tip", "Xorg nem található."), - ("xorg_not_found_text_tip", "Telepítse az Xorgot."), - ("no_desktop_title_tip", "Nem áll rendelkezésre asztali környezet."), - ("no_desktop_text_tip", "Telepítse a GNOME asztali környezetet."), ("No need to elevate", "Nem szükséges megemelni"), ("System Sound", "Rendszer hangok"), ("Default", "Alapértelmezett"), diff --git a/src/lang/id.rs b/src/lang/id.rs index ae313d69d..25c12040d 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Data ini identik dengan milik rekan"), ("show_monitors_tip", "Tampilkan monitor di toolbar"), ("View Mode", "Mode Tampilan"), - ("login_linux_tip", "Anda harus masuk ke akun remote linux untuk mengaktifkan sesi X desktop"), ("verify_rustdesk_password_tip", "Verifikasi Kata Sandi RustDesk"), - ("remember_account_tip", "Ingat akun ini"), - ("os_account_desk_tip", "Akun ini digunakan untuk masuk ke sistem operasi remote dan mengaktifkan sesi desktop dalam mode tanpa tampilan (headless)"), - ("OS Account", "Akun OS"), - ("another_user_login_title_tip", "Akun ini sedang digunakan"), - ("another_user_login_text_tip", "Putuskan koneksi diperangkat lain"), - ("xorg_not_found_title_tip", "Xorg tidak ditemukan"), - ("xorg_not_found_text_tip", "Silahkan install Xorg"), - ("no_desktop_title_tip", "Desktop tidak tersedia"), - ("no_desktop_text_tip", "Silahkan install GNOME Desktop"), ("No need to elevate", "Tidak perlu elevasi"), ("System Sound", "Suara Sistem"), ("Default", "Default"), diff --git a/src/lang/it.rs b/src/lang/it.rs index 1cac8ccee..330e5577a 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Questo file è identico a quello nel dispositivo remoto."), ("show_monitors_tip", "Visualizza schermi nella barra strumenti"), ("View Mode", "Modalità visualizzazione"), - ("login_linux_tip", "Accedi all'account Linux remoto"), ("verify_rustdesk_password_tip", "Conferma password RustDesk"), - ("remember_account_tip", "Ricorda questo account"), - ("os_account_desk_tip", "Questo account viene usato per accedere al sistema operativo remoto e attivare la sessione desktop in modalità non presidiata."), - ("OS Account", "Account sistema operativo"), - ("another_user_login_title_tip", "È già loggato un altro utente."), - ("another_user_login_text_tip", "Separato"), - ("xorg_not_found_title_tip", "Xorg non trovato."), - ("xorg_not_found_text_tip", "Installa Xorg."), - ("no_desktop_title_tip", "Non è presente alcun ambiente desktop disponibile."), - ("no_desktop_text_tip", "Installa il desktop GNOME."), ("No need to elevate", "Elevazione dei privilegi non richiesta"), ("System Sound", "Dispositivo audio sistema"), ("Default", "Predefinita"), diff --git a/src/lang/ja.rs b/src/lang/ja.rs index 30cedf355..f9ae7777e 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "このファイルはリモートコンピューターと同一です。"), ("show_monitors_tip", "ツールバーにディスプレイを表示する"), ("View Mode", "表示モード"), - ("login_linux_tip", "X デスクトップのセッションにログインするには、リモートコンピューターのLinuxアカウントにログインする必要があります。"), ("verify_rustdesk_password_tip", "RustDesk のパスワードを確認する"), - ("remember_account_tip", "このアカウントを記憶する"), - ("os_account_desk_tip", "このアカウントは、リモートコンピューターの OS にログインし、ヘッドレスでセッションを有効化するために使用されます。"), - ("OS Account", "OS のアカウント"), - ("another_user_login_title_tip", "他のユーザーがすでにログインしています"), - ("another_user_login_text_tip", "切断しました"), - ("xorg_not_found_title_tip", "Xorg サーバーが見つかりませんでした。"), - ("xorg_not_found_text_tip", "Xorg をインストールしてください"), - ("no_desktop_title_tip", "デスクトップ環境が見つかりませんでした。"), - ("no_desktop_text_tip", "GNOME デスクトップ環境をインストールしてください"), ("No need to elevate", "権限昇格の必要はありません"), ("System Sound", "システム音声"), ("Default", "既定"), diff --git a/src/lang/ko.rs b/src/lang/ko.rs index ca23c65ac..f7da53b3f 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "이 파일은 상대방의 파일과 일치합니다."), ("show_monitors_tip", "도구 모음에 모니터 표시"), ("View Mode", "보기 모드"), - ("login_linux_tip", "X 데스크탑을 활성화하려면 제어되는 터미널의 Linux 계정에 로그인하세요"), ("verify_rustdesk_password_tip", "RustDesk 비밀번호 확인"), - ("remember_account_tip", "이 계정 기억하기"), - ("os_account_desk_tip", "이 계정은 원격 OS에 로그인하고 헤드리스에서 데스크탑 세션을 활성화하는 데 사용됩니다."), - ("OS Account", "OS 계정"), - ("another_user_login_title_tip", "다른 사용자가 이미 로그인했습니다"), - ("another_user_login_text_tip", "연결 끊기"), - ("xorg_not_found_title_tip", "Xorg를 찾을 수 없습니다"), - ("xorg_not_found_text_tip", "Xorg를 설치해 주세요"), - ("no_desktop_title_tip", "사용 가능한 데스크탑 환경이 없습니다"), - ("no_desktop_text_tip", "GNOME 데스크탑을 설치해 주세요"), ("No need to elevate", "권한 상승이 필요없습니다"), ("System Sound", "시스템 소리"), ("Default", "기본"), diff --git a/src/lang/kz.rs b/src/lang/kz.rs index a194ed19f..89121acca 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Бұл файыл пирдікімен бірдей."), ("show_monitors_tip", "Мониторларды құралдар тақтасында көрсету"), ("View Mode", "Көру модасы"), - ("login_linux_tip", "X жұмыс үстелі сешін іске қосу үшін қашықтағы Linux есепкісіне кіруіңіз керек"), ("verify_rustdesk_password_tip", "RustDesk құпия сөзін тексеру"), - ("remember_account_tip", "Бұл есепкіні есте сақтау"), - ("os_account_desk_tip", "Бұл есепкі қашықтағы OS-қа кіру және headless режимде жұмыс үстелі сешін іске қосу үшін қолданылады"), - ("OS Account", "OS есепкісі"), - ("another_user_login_title_tip", "Басқа қолданушы әлдеқашан кіріп қойған"), - ("another_user_login_text_tip", "Ажырату"), - ("xorg_not_found_title_tip", "Xorg табылмады"), - ("xorg_not_found_text_tip", "Xorg орнатуды өтінеміз"), - ("no_desktop_title_tip", "Жұмыс үстелі ортасы қолжетімсіз"), - ("no_desktop_text_tip", "GNOME жұмыс үстелін орнатуды өтінеміз"), ("No need to elevate", "Артықшылықты көтерудің қажеті жоқ"), ("System Sound", "Жүйе дыбысы"), ("Default", "Әдепкі"), diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 4a638b697..eb19f21c2 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Failas yra identiškas nuotoliniame kompiuteryje esančiam failui."), ("show_monitors_tip", "Rodyti monitorius įrankių juostoje"), ("View Mode", "Peržiūros režimas"), - ("login_linux_tip", "Norėdami įjungti X darbalaukio seansą, turite būti prisijungę prie nuotolinės Linux paskyros."), ("verify_rustdesk_password_tip", "Įveskite kliento RustDesk slaptažodį"), - ("remember_account_tip", "Prisiminti šią paskyrą"), - ("os_account_desk_tip", "Ši paskyra naudojama norint prisijungti prie nuotolinės OS ir įgalinti darbalaukio seansą režimu headless"), - ("OS Account", "OS paskyra"), - ("another_user_login_title_tip", "Kitas vartotojas jau yra prisijungęs"), - ("another_user_login_text_tip", "Atjungti"), - ("xorg_not_found_title_tip", "Xorg nerastas"), - ("xorg_not_found_text_tip", "Prašom įdiegti Xorg"), - ("no_desktop_title_tip", "Nėra pasiekiamų nuotolinių darbalaukių"), - ("no_desktop_text_tip", "Prašom įdiegti GNOME Desktop"), ("No need to elevate", "Teisių kelti nereikia"), ("System Sound", "Sistemos garsas"), ("Default", "Numatytasis"), diff --git a/src/lang/lv.rs b/src/lang/lv.rs index f0a3ebcff..fe853cdff 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Šis fails ir identisks sesijas failam."), ("show_monitors_tip", "Rādīt monitorus rīkjoslā"), ("View Mode", "Skatīšanas režīms"), - ("login_linux_tip", "Jums ir jāpiesakās attālajā Linux kontā, lai iespējotu X darbvirsmas sesiju"), ("verify_rustdesk_password_tip", "Pārbaudīt RustDesk paroli"), - ("remember_account_tip", "Atcerēties šo kontu"), - ("os_account_desk_tip", "Šis konts tiek izmantots, lai pieteiktos attālajā operētājsistēmā un iespējotu darbvirsmas sesiju fonā"), - ("OS Account", "OS konts"), - ("another_user_login_title_tip", "Cits lietotājs jau ir pieteicies"), - ("another_user_login_text_tip", "Atvienot"), - ("xorg_not_found_title_tip", "Xorg nav atrasts"), - ("xorg_not_found_text_tip", "Lūdzu, instalējiet Xorg"), - ("no_desktop_title_tip", "Nav pieejama darbvirsma"), - ("no_desktop_text_tip", "Lūdzu, instalējiet GNOME darbvirsmu"), ("No need to elevate", "Nav nepieciešams paaugstināt"), ("System Sound", "Sistēmas skaņa"), ("Default", "Noklusējums"), diff --git a/src/lang/ml.rs b/src/lang/ml.rs index b982ee49f..fe8534a0e 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "ഈ ഫയൽ നിലവിലുണ്ട്."), ("show_monitors_tip", "ടൂൾബാറിൽ മോണിറ്ററുകൾ കാണിക്കുക"), ("View Mode", "വ്യൂ മോഡ്"), - ("login_linux_tip", "റിമോട്ട് ലിനക്സ് സെഷനായി ലോഗിൻ ചെയ്യണം"), ("verify_rustdesk_password_tip", "RustDesk പാസ്‌വേഡ് പരിശോധിക്കുക"), - ("remember_account_tip", "ഈ അക്കൗണ്ട് ഓർമ്മിക്കുക"), - ("os_account_desk_tip", "ആക്‌സസിനായി OS അക്കൗണ്ട് ഉപയോഗിക്കുക"), - ("OS Account", "OS അക്കൗണ്ട്"), - ("another_user_login_title_tip", "മറ്റൊരു ഉപയോക്താവ് ലോഗിൻ ചെയ്തിട്ടുണ്ട്"), - ("another_user_login_text_tip", "വിച്ഛേദിച്ച ശേഷം വീണ്ടും ശ്രമിക്കുക"), - ("xorg_not_found_title_tip", "Xorg കണ്ടെത്താനായില്ല"), - ("xorg_not_found_text_tip", "ദയവായി Xorg ഇൻസ്റ്റാൾ ചെയ്യുക"), - ("no_desktop_title_tip", "ഡെസ്ക്ടോപ്പ് ലഭ്യമല്ല"), - ("no_desktop_text_tip", "ദയവായി ലിനക്സ് ഡെസ്ക്ടോപ്പ് ഇൻസ്റ്റാൾ ചെയ്യുക"), ("No need to elevate", "എലവേറ്റ് ചെയ്യേണ്ടതില്ല"), ("System Sound", "സിസ്റ്റം സൗണ്ട്"), ("Default", "ഡിഫോൾട്ട്"), diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 7dba8d4a5..45bd5c540 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Denne filen er identisk med motpartens fil."), ("show_monitors_tip", "Vis skjermer i verktøylinjen"), ("View Mode", "Visningsmodus"), - ("login_linux_tip", "Du må logge inn på den eksterne Linux-kontoen for å aktivere en X-skrivebordssesjon"), ("verify_rustdesk_password_tip", "Verifiser RustDesk-passord"), - ("remember_account_tip", "Husk denne kontoen"), - ("os_account_desk_tip", "Denne kontoen brukes til å logge inn på det eksterne operativsystemet og aktivere skrivebordssesjonen i hodeløs modus"), - ("OS Account", "OS-konto"), - ("another_user_login_title_tip", "En annen bruker er allerede logget inn"), - ("another_user_login_text_tip", "Koble fra"), - ("xorg_not_found_title_tip", "Xorg ikke funnet"), - ("xorg_not_found_text_tip", "Vennligst installer Xorg"), - ("no_desktop_title_tip", "Ingen skrivebordsmiljø er tilgjengelig"), - ("no_desktop_text_tip", "Vennligst installer GNOME-skrivebordet"), ("No need to elevate", "Ikke behov for elevering"), ("System Sound", "Systemlyd"), ("Default", "Standard"), diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 0470a4b4f..b0d21f97f 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Dit bestand is identiek aan het bestand van het externe station."), ("show_monitors_tip", "Monitoren weergeven in de werkbalk"), ("View Mode", "Toeschouwermodus"), - ("login_linux_tip", "Toegang tot het externe Linux-account"), ("verify_rustdesk_password_tip", "Bevestiging wachtwoord RustDesk"), - ("remember_account_tip", "Onthoud dit account"), - ("os_account_desk_tip", "Dit account wordt gebruikt om toegang te krijgen tot het externe besturingssysteem en de bureaubladsessie in onbeheerde modus te activeren."), - ("OS Account", "Besturingssysteem account"), - ("another_user_login_title_tip", "Een andere gebruiker is al ingelogd."), - ("another_user_login_text_tip", "Afzonderlijk"), - ("xorg_not_found_title_tip", "Xorg niet gevonden."), - ("xorg_not_found_text_tip", "Installeer Xorg."), - ("no_desktop_title_tip", "Er is geen desktop beschikbaar."), - ("no_desktop_text_tip", "Installeer de GNOME desktop."), ("No need to elevate", "Niet nodig om te verhogen"), ("System Sound", "Systeemgeluid"), ("Default", "Standaard"), diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 44efea50c..120183803 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Ten plik jest identyczny z plikiem na drugim komputerze."), ("show_monitors_tip", "Pokaż monitory w zasobniku"), ("View Mode", "Tylko podgląd (wyłącza możliwość interakcji)"), - ("login_linux_tip", "Musisz zalogować się na zdalne konto, by zezwolić na sesję pulpitu X"), ("verify_rustdesk_password_tip", "Weryfikuj hasło RustDesk"), - ("remember_account_tip", "Zapamiętaj to konto"), - ("os_account_desk_tip", "To konto jest używane do logowania do zdalnych systemów i włącza bezobsługowe sesje pulpitu"), - ("OS Account", "Konto systemowe"), - ("another_user_login_title_tip", "Inny użytkownik jest już zalogowany"), - ("another_user_login_text_tip", "Rozłącz"), - ("xorg_not_found_title_tip", "Nie znaleziono Xorg"), - ("xorg_not_found_text_tip", "Proszę zainstalować Xorg"), - ("no_desktop_title_tip", "Żaden pulpit nie jest dostępny"), - ("no_desktop_text_tip", "Proszę zainstalować pulpit GNOME"), ("No need to elevate", "Podniesienie uprawnień nie jest wymagane"), ("System Sound", "Dźwięk systemowy"), ("Default", "Domyślne"), diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index a162522bb..7d033b363 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Este ficheiro é idêntico ao do destino."), ("show_monitors_tip", "Mostrar monitores na barra de ferramentas"), ("View Mode", "Modo de visualização"), - ("login_linux_tip", "É necessário iniciar sessão na conta Linux remota para ativar uma sessão de ambiente de trabalho X"), ("verify_rustdesk_password_tip", "Verificar palavra-passe do RustDesk"), - ("remember_account_tip", "Memorizar esta conta"), - ("os_account_desk_tip", "Esta conta é usada para iniciar sessão no SO remoto e ativar a sessão de ambiente de trabalho em modo headless"), - ("OS Account", "Conta do SO"), - ("another_user_login_title_tip", "Outro utilizador já tem sessão iniciada"), - ("another_user_login_text_tip", "Desligar"), - ("xorg_not_found_title_tip", "Xorg não encontrado"), - ("xorg_not_found_text_tip", "Instale o Xorg"), - ("no_desktop_title_tip", "Não há nenhum ambiente de trabalho disponível"), - ("no_desktop_text_tip", "Instale o ambiente de trabalho GNOME"), ("No need to elevate", "Não é necessário elevar"), ("System Sound", "Som do sistema"), ("Default", "Predefinido"), diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 070fc0b7b..897ef1735 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Este arquivo é idêntico ao do parceiro."), ("show_monitors_tip", "Mostrar telas na barra de ferramentas"), ("View Mode", "Modo de visualização"), - ("login_linux_tip", "Você precisa fazer login na conta Linux remota para habilitar uma sessão de desktop X"), ("verify_rustdesk_password_tip", "Verifique a senha do RustDesk"), - ("remember_account_tip", "Lembrar desta conta"), - ("os_account_desk_tip", "Esta conta é usada para fazer login no Sistema Operacional remoto e habilitar a sessão da área de trabalho em headless"), - ("OS Account", "Conta do Sistema Operacional"), - ("another_user_login_title_tip", "Outro usuário já está logado"), - ("another_user_login_text_tip", "Desconectar"), - ("xorg_not_found_title_tip", "Xorg não encontrado"), - ("xorg_not_found_text_tip", "Por favor, instale o Xorg"), - ("no_desktop_title_tip", "Nenhuma área de trabalho está disponível"), - ("no_desktop_text_tip", "Por favor, instale a área de trabalho do GNOME"), ("No need to elevate", "Não há necessidade de elevar"), ("System Sound", "Som do Sistema"), ("Default", "Padrão"), diff --git a/src/lang/ro.rs b/src/lang/ro.rs index 5bc7a5e02..aee37cf94 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Acest fișier este identic cu cel al dispozitivului pereche."), ("show_monitors_tip", "Afișează monitoare în bara de instrumente"), ("View Mode", "Mod vizualizare"), - ("login_linux_tip", "Este necesar să te conectezi la contul de Linux de la distanță pentru a începe o sesiune cu un desktop care folosește X11"), ("verify_rustdesk_password_tip", "Verifică parola RustDesk"), - ("remember_account_tip", "Reține contul"), - ("os_account_desk_tip", "Acest cont este utilizat pentru conectarea la sistemul de operare la distanță și începerea sesiunii cu desktopul în modul fără afișaj."), - ("OS Account", "Cont OS"), - ("another_user_login_title_tip", "Un alt utilizator este deja conectat"), - ("another_user_login_text_tip", "Deconectare"), - ("xorg_not_found_title_tip", "Xorg nu a fost găsit"), - ("xorg_not_found_text_tip", "Instalează Xorg"), - ("no_desktop_title_tip", "Nu este disponibil niciun mediu desktop"), - ("no_desktop_text_tip", "Instalează mediul desktop GNOME"), ("No need to elevate", "Nu sunt necesare permisiuni de administrator"), ("System Sound", "Sunet sistem"), ("Default", "Implicit"), diff --git a/src/lang/ru.rs b/src/lang/ru.rs index a0ca5affe..834fcd565 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Файл идентичен файлу на удалённом узле"), ("show_monitors_tip", "Показывать мониторы на панели инструментов"), ("View Mode", "Режим просмотра"), - ("login_linux_tip", "Чтобы включить сеанс рабочего стола X, необходимо войти в удалённый аккаунт Linux."), ("verify_rustdesk_password_tip", "Подтвердить пароль RustDesk"), - ("remember_account_tip", "Запомнить этот аккаунт"), - ("os_account_desk_tip", "Этот аккаунт используется для входа в удалённую ОС и включения сеанса рабочего стола в режиме headless."), - ("OS Account", "Аккаунт ОС"), - ("another_user_login_title_tip", "Другой пользователь уже вошёл в систему"), - ("another_user_login_text_tip", "Отключить"), - ("xorg_not_found_title_tip", "Xorg не найден"), - ("xorg_not_found_text_tip", "Установите Xorg"), - ("no_desktop_title_tip", "Нет доступных рабочих столов"), - ("no_desktop_text_tip", "Установите GNOME Desktop"), ("No need to elevate", "Повышение прав не требуется"), ("System Sound", "Системный звук"), ("Default", "По умолчанию"), diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 1228e9876..59d0967c6 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Custu archìviu est pretzisu a su chi b'at in su dispositivu remotu."), ("show_monitors_tip", "Mustra sos ischermos in s'istanga de sos trastes"), ("View Mode", "Modalidade de visualizatzione"), - ("login_linux_tip", "Intra a su contu de Linux remotu"), ("verify_rustdesk_password_tip", "Cunfirma sa crae de RustDesk"), - ("remember_account_tip", "Ammenta custu contu"), - ("os_account_desk_tip", "Custu contu s'impreat pro intrare a su sistema operativu remotu e ativare sa sessione de s'elaboradore in modalidade non presidiada."), - ("OS Account", "Contu sistema operativu"), - ("another_user_login_title_tip", "Un'àteru utente at giai fatu s'atzessu."), - ("another_user_login_text_tip", "Separadu"), - ("xorg_not_found_title_tip", "Xorg no atzapadu."), - ("xorg_not_found_text_tip", "Installa Xorg."), - ("no_desktop_title_tip", "Non b'at perunu ambiente de elaboradore a disponimentu."), - ("no_desktop_text_tip", "Installa s'ambiente de elaboradore GNOME."), ("No need to elevate", "Crèschida de sos privilègios non pedida"), ("System Sound", "Dispositivu àudio de sistema"), ("Default", "Predefinida"), diff --git a/src/lang/sk.rs b/src/lang/sk.rs index 5460e2b19..f01cf6e3a 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Tento súbor je identický so súborom partnera."), ("show_monitors_tip", "Zobraziť monitory na paneli nástrojov"), ("View Mode", "Režim zobrazenia"), - ("login_linux_tip", "Ak chcete povoliť reláciu Desktop X, musíte sa prihlásiť do vzdialeného konta Linuxu."), ("verify_rustdesk_password_tip", "Overenie hesla RustDesk"), - ("remember_account_tip", "Zapamätať si tento účet"), - ("os_account_desk_tip", "Toto konto sa používa na prihlásenie do vzdialeného operačného systému a na povolenie relácie pracovnej plochy v režime headless."), - ("OS Account", "Účet operačného systému"), - ("another_user_login_title_tip", "Ďalší používateľ je už prihlásený"), - ("another_user_login_text_tip", "Odpojiť"), - ("xorg_not_found_title_tip", "Xorg nebol nájdený"), - ("xorg_not_found_text_tip", "Prosím, nainštalujte Xorg"), - ("no_desktop_title_tip", "Nie je k dispozícii žiadna plocha"), - ("no_desktop_text_tip", "Nainštalujte si prostredie GNOME"), ("No need to elevate", "Navýšenie nie je potrebné"), ("System Sound", "Systémový zvuk"), ("Default", "Predvolené"), diff --git a/src/lang/sl.rs b/src/lang/sl.rs index d9d5fd172..04a0dd0e2 100644 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Datoteka je enaka partnerjevi"), ("show_monitors_tip", "Prikaži monitorje v orodni vrstici"), ("View Mode", "Način prikazovanja"), - ("login_linux_tip", "Prijaviti se morate v oddaljeni Linux račun in omogočiti namizno sejo X."), ("verify_rustdesk_password_tip", "Preveri geslo za RustDesk"), - ("remember_account_tip", "Zapomni si ta račun"), - ("os_account_desk_tip", "Ta račun se uporabi za prijavo v oddaljeni sistem in omogči namizno sejo v napravi brez monitorja."), - ("OS Account", "Račun operacijskega sistema"), - ("another_user_login_title_tip", "Prijavljen je že drug uporabnik"), - ("another_user_login_text_tip", "Prekini"), - ("xorg_not_found_title_tip", "Xorg ni najden"), - ("xorg_not_found_text_tip", "Namestite Xorg"), - ("no_desktop_title_tip", "Namizno okolje ni na voljo"), - ("no_desktop_text_tip", "Namestite GNOME"), ("No need to elevate", "Povzdig pravic ni potreben"), ("System Sound", "Sistemski zvok"), ("Default", "Privzeto"), diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 40b1060e7..2fb1c811d 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Ky skedar është identik me atë të peer-it."), ("show_monitors_tip", "Shfaq monitorët në shiritin e veglave"), ("View Mode", "Modaliteti i pamjes"), - ("login_linux_tip", "Duhet të hyni në llogarinë Linux në distancë për të aktivizuar një seancë desktopi X"), ("verify_rustdesk_password_tip", "Verifiko fjalëkalimin e RustDesk"), - ("remember_account_tip", "Mbaj mend këtë llogari"), - ("os_account_desk_tip", "Kjo llogari përdoret për të hyrë në OS-në në distancë dhe për të aktivizuar seancën e desktopit pa ekran"), - ("OS Account", "Llogaria e OS"), - ("another_user_login_title_tip", "Një përdorues tjetër ka hyrë tashmë"), - ("another_user_login_text_tip", "Shkëput"), - ("xorg_not_found_title_tip", "Xorg nuk u gjet"), - ("xorg_not_found_text_tip", "Ju lutemi instaloni Xorg"), - ("no_desktop_title_tip", "Nuk ka asnjë mjedis desktopi të disponueshëm"), - ("no_desktop_text_tip", "Ju lutemi instaloni desktopin GNOME"), ("No need to elevate", "Nuk ka nevojë për ngritje privilegjesh"), ("System Sound", "Tingulli i sistemit"), ("Default", "I parazgjedhur"), diff --git a/src/lang/sr.rs b/src/lang/sr.rs index 390afb5e5..e1b0e703d 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Ova datoteka je identična sa onom kod klijenta."), ("show_monitors_tip", "Prikaži monitore u traci alata"), ("View Mode", "Režim prikaza"), - ("login_linux_tip", "Potrebno je da se prijavite na udaljeni Linux nalog da biste omogućili X desktop sesiju"), ("verify_rustdesk_password_tip", "Potvrdi RustDesk lozinku"), - ("remember_account_tip", "Zapamti ovaj nalog"), - ("os_account_desk_tip", "Ovaj nalog se koristi za prijavu na udaljeni OS i omogućavanje desktop sesije u headless režimu"), - ("OS Account", "OS nalog"), - ("another_user_login_title_tip", "Drugi korisnik je već prijavljen"), - ("another_user_login_text_tip", "Prekini vezu"), - ("xorg_not_found_title_tip", "Xorg nije pronađen"), - ("xorg_not_found_text_tip", "Molimo instalirajte Xorg"), - ("no_desktop_title_tip", "Nijedno desktop okruženje nije dostupno"), - ("no_desktop_text_tip", "Molimo instalirajte GNOME desktop"), ("No need to elevate", "Nema potrebe za podizanjem privilegija"), ("System Sound", "Sistemski zvuk"), ("Default", "Podrazumevano"), diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 142f18338..594efa688 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Den här filen är identisk med klientens."), ("show_monitors_tip", "Visa skärmar i verktygsfältet"), ("View Mode", "Visningsläge"), - ("login_linux_tip", "Du måste logga in på Linux-fjärrkontot för att aktivera en X-skrivbordssession"), ("verify_rustdesk_password_tip", "Verifiera RustDesk-lösenord"), - ("remember_account_tip", "Kom ihåg detta konto"), - ("os_account_desk_tip", "Detta konto används för att logga in på fjärroperativsystemet och aktivera skrivbordssessionen i obevakat läge"), - ("OS Account", "OS-konto"), - ("another_user_login_title_tip", "En annan användare är redan inloggad"), - ("another_user_login_text_tip", "Koppla ifrån"), - ("xorg_not_found_title_tip", "Xorg hittades inte"), - ("xorg_not_found_text_tip", "Installera Xorg"), - ("no_desktop_title_tip", "Ingen skrivbordsmiljö är tillgänglig"), - ("no_desktop_text_tip", "Installera GNOME-skrivbordet"), ("No need to elevate", "Ingen behörighetshöjning behövs"), ("System Sound", "Systemljud"), ("Default", "Standard"), diff --git a/src/lang/ta.rs b/src/lang/ta.rs index c6221a841..8a4afde95 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "ஒரே_மாதிரியான_கோப்பு_குறிப்பு"), ("show_monitors_tip", "மானிட்டர்களை_காட்டு_குறிப்பு"), ("View Mode", "காட்சி முறை"), - ("login_linux_tip", "லினக்ஸ்_உள்நுழைவு_குறிப்பு"), ("verify_rustdesk_password_tip", "rustdesk_கடவுச்சொல்_சரிபார்ப்பு_குறிப்பு"), - ("remember_account_tip", "கணக்கை_நினைவில்_கொள்_குறிப்பு"), - ("os_account_desk_tip", "os_கணக்கு_டெஸ்க்_குறிப்பு"), - ("OS Account", "OS கணக்கு"), - ("another_user_login_title_tip", "மற்றொரு_பயனர்_உள்நுழைவு_தலைப்பு_குறிப்பு"), - ("another_user_login_text_tip", "மற்றொரு_பயனர்_உள்நுழைவு_உரை_குறிப்பு"), - ("xorg_not_found_title_tip", "xorg_காணப்படவில்லை_தலைப்பு_குறிப்பு"), - ("xorg_not_found_text_tip", "xorg_காணப்படவில்லை_உரை_குறிப்பு"), - ("no_desktop_title_tip", "டெஸ்க்டாப்_இல்லை_தலைப்பு_குறிப்பு"), - ("no_desktop_text_tip", "டெஸ்க்டாப்_இல்லை_உரை_குறிப்பு"), ("No need to elevate", "உயர்த்த தேவையில்லை"), ("System Sound", "சிஸ்டம் ஒலி"), ("Default", "இயல்புநிலை"), diff --git a/src/lang/template.rs b/src/lang/template.rs index c529dfada..83497a0f6 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", ""), ("show_monitors_tip", ""), ("View Mode", ""), - ("login_linux_tip", ""), ("verify_rustdesk_password_tip", ""), - ("remember_account_tip", ""), - ("os_account_desk_tip", ""), - ("OS Account", ""), - ("another_user_login_title_tip", ""), - ("another_user_login_text_tip", ""), - ("xorg_not_found_title_tip", ""), - ("xorg_not_found_text_tip", ""), - ("no_desktop_title_tip", ""), - ("no_desktop_text_tip", ""), ("No need to elevate", ""), ("System Sound", ""), ("Default", ""), diff --git a/src/lang/th.rs b/src/lang/th.rs index d956e7144..31f314726 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "ไฟล์นี้เหมือนกับไฟล์ของอีกฝั่ง"), ("show_monitors_tip", "แสดงหน้าจอในแถบเครื่องมือ"), ("View Mode", "โหมดการดู"), - ("login_linux_tip", "คุณจำเป็นจะต้องเข้าสู่ระบบไปยังบัญชีลินุกซ์ปลายทางเพื่อใช้งานเดสก์ท็อปเซสชัน X"), ("verify_rustdesk_password_tip", "ยืนยันความถูกต้องรหัสผ่านของ RustDesk"), - ("remember_account_tip", "จดจำบัญชีนี้"), - ("os_account_desk_tip", "บัญชีนี้จะถูกใช้ในการเข้าสู่ระบบเครื่องปลายทางและเริ่มใช้งานเดสก์ท็อปเซสชันแบบ headless"), - ("OS Account", "บัญชีระบบปฏิบัติการ"), - ("another_user_login_title_tip", "ผู้ใช้งานอื่นเข้าสู่ระบบอยู่แล้ว"), - ("another_user_login_text_tip", "ยกเลิกการเชื่อมต่อ"), - ("xorg_not_found_title_tip", "ไม่พบ Xorg"), - ("xorg_not_found_text_tip", "กรุณาติดตั้ง Xorg"), - ("no_desktop_title_tip", "ไม่มีหน้าเดสก์ท็อปที่ใช้งานได้"), - ("no_desktop_text_tip", "กรุณาติดตั้ง GNOME เดสกท็อป"), ("No need to elevate", "ไม่จำเป็นต้องยกระดับสิทธิ์การใช้งาน"), ("System Sound", "เสียงของระบบ"), ("Default", "ค่าเริ่มต้น"), diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 1f0c3dcd8..66ac42a1c 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Bu dosya, cihazın dosyası ile aynıdır."), ("show_monitors_tip", "Monitörleri araç çubuğunda göster"), ("View Mode", "Görünüm Modu"), - ("login_linux_tip", "X masaüstü oturumu başlatmak için uzaktaki Linux hesabına giriş yapmanız gerekiyor"), ("verify_rustdesk_password_tip", "RustDesk parolasını doğrulayın"), - ("remember_account_tip", "Bu hesabı hatırla"), - ("os_account_desk_tip", "Bu hesap, uzaktaki işletim sistemine giriş yapmak ve başsız masaüstü oturumunu etkinleştirmek için kullanılır."), - ("OS Account", "İşletim Sistemi Hesabı"), - ("another_user_login_title_tip", "Başka bir kullanıcı zaten oturum açtı"), - ("another_user_login_text_tip", "Bağlantıyı Kapat"), - ("xorg_not_found_title_tip", "Xorg bulunamadı"), - ("xorg_not_found_text_tip", "Lütfen Xorg'u yükleyin"), - ("no_desktop_title_tip", "Masaüstü mevcut değil"), - ("no_desktop_text_tip", "Lütfen GNOME masaüstünü yükleyin"), ("No need to elevate", "Yükseltmeye gerek yok"), ("System Sound", "Sistem Sesi"), ("Default", "Varsayılan"), diff --git a/src/lang/tw.rs b/src/lang/tw.rs index eb05f9e7c..b35322d10 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "此檔案與對方的檔案一致。"), ("show_monitors_tip", "在工具列中顯示顯示器"), ("View Mode", "瀏覽模式"), - ("login_linux_tip", "需要登入到遠端 Linux 使用者帳戶才能啟用 X 桌面環境"), ("verify_rustdesk_password_tip", "驗證 RustDesk 密碼"), - ("remember_account_tip", "記住此使用者帳戶"), - ("os_account_desk_tip", "此使用者帳戶將用於登入遠端作業系統並啟用無頭模式 (headless mode) 的桌面連線"), - ("OS Account", "作業系統使用者帳戶"), - ("another_user_login_title_tip", "另一個使用者已經登入"), - ("another_user_login_text_tip", "斷開連線"), - ("xorg_not_found_title_tip", "找不到 Xorg"), - ("xorg_not_found_text_tip", "請安裝 Xorg"), - ("no_desktop_title_tip", "沒有可用的桌面環境"), - ("no_desktop_text_tip", "請安裝 GNOME 桌面"), ("No need to elevate", "不需要提升權限"), ("System Sound", "系統音效"), ("Default", "預設"), diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 4b61936e4..281b9cd6c 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Цей файл ідентичний з тим, що на вузлі"), ("show_monitors_tip", "Показувати монітори на панелі інструментів"), ("View Mode", "Режим перегляду"), - ("login_linux_tip", "Вам необхідно увійти у віддалений обліковий запис Linux, щоб увімкнути стільничний сеанс X"), ("verify_rustdesk_password_tip", "Перевірте пароль RustDesk"), - ("remember_account_tip", "Запамʼятати цей обліковий запис"), - ("os_account_desk_tip", "Цей обліковий запис використовується для входу до віддаленої ОС та вмикання сеансу стільниці в режимі без графічного інтерфейсу"), - ("OS Account", "Користувач ОС"), - ("another_user_login_title_tip", "Інший користувач вже в системі"), - ("another_user_login_text_tip", "Відʼєднатися"), - ("xorg_not_found_title_tip", "Xorg не знайдено"), - ("xorg_not_found_text_tip", "Будь ласка, встановіть Xorg"), - ("no_desktop_title_tip", "Жодне стільничне середовище не доступне"), - ("no_desktop_text_tip", "Будь ласка, встановіть стільничне середовище GNOME"), ("No need to elevate", "Немає потреби в розширенні прав"), ("System Sound", "Системний звук"), ("Default", "Типово"), diff --git a/src/lang/vi.rs b/src/lang/vi.rs index a3102800b..c9b28b949 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -464,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Tệp này giống hệt ở phía đối tác."), ("show_monitors_tip", "Hiện màn hình trên thanh công cụ"), ("View Mode", "Chế độ xem"), - ("login_linux_tip", "Cần đăng nhập tài khoản Linux để kích hoạt X session."), ("verify_rustdesk_password_tip", "Xác thực mật khẩu RustDesk"), - ("remember_account_tip", "Nhớ tài khoản này"), - ("os_account_desk_tip", "Tài khoản OS được dùng để đăng nhập và chạy session không màn hình (headless)."), - ("OS Account", "Tài khoản OS"), - ("another_user_login_title_tip", "Người dùng khác đã đăng nhập"), - ("another_user_login_text_tip", "Ngắt kết nối hiện tại"), - ("xorg_not_found_title_tip", "Không tìm thấy Xorg"), - ("xorg_not_found_text_tip", "Vui lòng cài đặt Xorg"), - ("no_desktop_title_tip", "Không có desktop"), - ("no_desktop_text_tip", "Vui lòng cài đặt GNOME hoặc desktop khác."), ("No need to elevate", "Không cần nâng quyền"), ("System Sound", "Âm thanh hệ thống"), ("Default", "Mặc định"), diff --git a/src/platform/linux.rs b/src/platform/linux.rs index f67952e9b..d187b0ec2 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -14,7 +14,7 @@ use hbb_common::{ allow_err, anyhow::anyhow, bail, - config::{keys::OPTION_ALLOW_LINUX_HEADLESS, Config}, + config::Config, libc::{c_char, c_int, c_long, c_uint, c_ulong, c_void}, log, message_proto::{DisplayInfo, Resolution}, @@ -235,11 +235,6 @@ pub struct xcb_xfixes_get_cursor_image { pub pixels: *const c_long, } -#[inline] -pub fn is_headless_allowed() -> bool { - Config::get_option(OPTION_ALLOW_LINUX_HEADLESS) == "Y" -} - #[inline] pub fn is_login_screen_wayland() -> bool { let values = get_values_of_seat0_with_gdm_wayland(&[0, 2]); @@ -863,18 +858,6 @@ fn stop_rustdesk_servers() { )); } -#[inline] -fn stop_subprocess() { - let _ = run_cmds(&format!( - r##"ps -ef | grep '/etc/{}/xorg.conf' | grep -v grep | awk '{{print $2}}' | xargs -r kill -9"##, - crate::get_app_name().to_lowercase(), - )); - let _ = run_cmds(&format!( - r##"ps -ef | grep -E '{} +--cm-no-ui' | grep -v grep | awk '{{print $2}}' | xargs -r kill -9"##, - crate::get_app_name().to_lowercase(), - )); -} - fn should_start_server( try_x11: bool, is_display_changed: bool, @@ -888,13 +871,7 @@ fn should_start_server( let mut start_new = false; let mut should_kill = false; - if desktop.is_headless() { - if !uid.is_empty() { - // From having a monitor to not having a monitor. - *uid = "".to_owned(); - should_kill = true; - } - } else if is_display_changed || desktop.uid != *uid && !desktop.uid.is_empty() { + if is_display_changed || desktop.uid != *uid && !desktop.uid.is_empty() { *uid = desktop.uid.clone(); if try_x11 { set_x11_env(&desktop); @@ -953,7 +930,6 @@ fn force_stop_server() { pub fn start_os_service() { check_if_stop_service(); stop_rustdesk_servers(); - stop_subprocess(); start_uinput_service(); std::thread::spawn(|| { @@ -1003,8 +979,7 @@ pub fn start_os_service() { desktop.refresh(); update_active_user_lookup_cache(&desktop); - // Duplicate logic here with should_start_server - // Login wayland will try to start a headless --server. + // Duplicate logic here with should_start_server. if desktop.username == "root" || desktop.is_login_wayland() { // try kill subprocess "--server" stop_server(&mut user_server); @@ -1019,7 +994,6 @@ pub fn start_os_service() { &mut last_restart, &mut server, ) { - stop_subprocess(); force_stop_server(); // Run the login-screen --server as the active seat0 session user (the greeter // account) rather than root, so the DRM capture GPU/EGL convert never loads the @@ -1072,7 +1046,6 @@ pub fn start_os_service() { &mut last_restart, &mut user_server, ) { - stop_subprocess(); force_stop_server(); start_server(Some(&desktop), &mut user_server); } @@ -1082,17 +1055,14 @@ pub fn start_os_service() { stop_server(&mut server); } - let keeps_headless = sid.is_empty() && desktop.is_headless(); let keeps_session = sid == desktop.sid; - if keeps_headless || keeps_session { + if keeps_session { // for fixing https://github.com/rustdesk/rustdesk/issues/3129 to avoid too much dbus calling, sleep_millis(500); } else { sleep_millis(super::SERVICE_INTERVAL); } - if !desktop.is_headless() { - sid = desktop.sid.clone(); - } + sid = desktop.sid.clone(); } if let Some(ps) = user_server.take().as_mut() { @@ -1249,7 +1219,6 @@ fn is_flatpak() -> bool { std::path::PathBuf::from("/.flatpak-info").exists() } -// Headless is enabled, always return true. pub fn is_prelogin() -> bool { if is_flatpak() { return false; @@ -1860,7 +1829,6 @@ mod desktop { pub xauth: String, pub home: String, pub dbus: String, - pub is_rustdesk_subprocess: bool, pub wl_display: String, } @@ -1875,11 +1843,6 @@ mod desktop { super::is_gdm_user(&self.username) && self.protocol == DISPLAY_SERVER_WAYLAND } - #[inline] - pub fn is_headless(&self) -> bool { - self.sid.is_empty() || self.is_rustdesk_subprocess - } - fn get_display_xauth_wayland(&mut self) { for _ in 1..=10 { // Prefer Wayland-related variables first when multiple portal processes match. @@ -2117,25 +2080,11 @@ mod desktop { last } - fn set_is_subprocess(&mut self) { - self.is_rustdesk_subprocess = false; - let cmd = format!( - "ps -ef | grep '{}/xorg.conf' | grep -v grep | wc -l", - crate::get_app_name().to_lowercase() - ); - if let Ok(res) = run_cmds(&cmd) { - if res.trim() != "0" { - self.is_rustdesk_subprocess = true; - } - } - } - pub fn refresh(&mut self) { if !self.sid.is_empty() && is_active_and_seat0(&self.sid) { // Xwayland display and xauth may not be available in a short time after login. if is_xwayland_running() && !self.is_login_wayland() { self.get_display_xauth_xwayland(); - self.is_rustdesk_subprocess = false; } else if self.is_wayland() { self.get_display_xauth_wayland(); } @@ -2145,7 +2094,6 @@ mod desktop { let seat0_values = get_values_of_seat0_with_gdm_wayland(&[0, 1, 2]); if seat0_values[0].is_empty() { *self = Self::default(); - self.is_rustdesk_subprocess = false; return; } @@ -2156,7 +2104,6 @@ mod desktop { if self.is_login_wayland() { self.display = "".to_owned(); self.xauth = "".to_owned(); - self.is_rustdesk_subprocess = false; // Resolve HOME even on this path. Upstream returned without it because nothing then // consumed a login-Wayland Desktop, but the drm build starts a `--server` as the // greeter uid here, and a child with no HOME has nowhere to put its config. The @@ -2183,11 +2130,9 @@ mod desktop { } else { self.get_display_xauth_wayland(); } - self.is_rustdesk_subprocess = false; } else { self.get_display_x11(); self.get_xauth_x11(); - self.set_is_subprocess(); } } } diff --git a/src/platform/linux_desktop_manager.rs b/src/platform/linux_desktop_manager.rs deleted file mode 100644 index 573dfa018..000000000 --- a/src/platform/linux_desktop_manager.rs +++ /dev/null @@ -1,1363 +0,0 @@ -use super::{linux::*, ResultType}; -use crate::client::{ - LOGIN_MSG_DESKTOP_NO_DESKTOP, LOGIN_MSG_DESKTOP_SESSION_ANOTHER_USER, - LOGIN_MSG_DESKTOP_SESSION_NOT_READY, LOGIN_MSG_DESKTOP_XORG_NOT_FOUND, - LOGIN_MSG_DESKTOP_XSESSION_FAILED, LOGIN_MSG_PASSWORD_WRONG, -}; -use hbb_common::{ - allow_err, bail, log, - rand::prelude::*, - tokio::time, - users::{get_user_by_name, os::unix::UserExt, User}, -}; -use pam; -use std::{ - collections::HashMap, - os::unix::process::CommandExt, - path::Path, - process::{Child, Command}, - sync::{ - atomic::{AtomicBool, AtomicUsize, Ordering}, - mpsc::{sync_channel, SyncSender}, - Arc, Mutex, - }, - time::{Duration, Instant}, -}; - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -struct Seat0Snapshot { - sequence: usize, - username: Option>, -} - -lazy_static::lazy_static! { - static ref DESKTOP_RUNNING: Arc = Arc::new(AtomicBool::new(false)); - static ref DESKTOP_MANAGER: Arc>> = Arc::new(Mutex::new(None)); - /// Last settled "who owns seat0" answer, for the PRE-AUTH path only; see `is_headless`. - static ref SEAT0_SNAPSHOT: Mutex = Mutex::new(Seat0Snapshot::default()); - static ref SEAT0_NEXT_REFRESH: Mutex> = Mutex::new(None); -} - -static SEAT0_REFRESH_IN_FLIGHT: AtomicBool = AtomicBool::new(false); -const FIRST_SEAT0_QUERY_SEQUENCE: usize = 1; -static SEAT0_QUERY_SEQUENCE: AtomicUsize = AtomicUsize::new(FIRST_SEAT0_QUERY_SEQUENCE); -const SEAT0_REFRESH_INTERVAL: Duration = Duration::from_secs(1); - -#[derive(Debug)] -struct DesktopManager { - child_username: String, - child_exit: Arc, - is_child_running: Arc, -} - -fn check_desktop_manager() { - let mut desktop_manager = DESKTOP_MANAGER.lock().unwrap(); - if let Some(desktop_manager) = &mut (*desktop_manager) { - if desktop_manager.is_child_running.load(Ordering::SeqCst) { - return; - } - desktop_manager.child_exit.store(true, Ordering::SeqCst); - } -} - -pub fn start_xdesktop() { - debug_assert!(crate::is_server()); - std::thread::spawn(|| { - DesktopManager::recover_orphaned_session(); - *DESKTOP_MANAGER.lock().unwrap() = Some(DesktopManager::new()); - // Seed the pre-auth snapshot now, off the connection path: without this the first - // connection of every server process would read no snapshot at all. - kick_seat0_refresh(); - - let interval = time::Duration::from_millis(super::SERVICE_INTERVAL); - DESKTOP_RUNNING.store(true, Ordering::SeqCst); - while DESKTOP_RUNNING.load(Ordering::SeqCst) { - check_desktop_manager(); - std::thread::sleep(interval); - } - log::info!("xdesktop child thread exit"); - }); -} - -pub fn stop_xdesktop() { - DESKTOP_RUNNING.store(false, Ordering::SeqCst); - *DESKTOP_MANAGER.lock().unwrap() = None; -} - -fn detect_headless() -> Option<&'static str> { - match run_cmds(&format!("which {}", DesktopManager::get_xorg())) { - Ok(output) => { - if output.trim().is_empty() { - return Some(LOGIN_MSG_DESKTOP_XORG_NOT_FOUND); - } - } - _ => { - return Some(LOGIN_MSG_DESKTOP_XORG_NOT_FOUND); - } - } - - match run_cmds("ls /usr/share/xsessions/") { - Ok(output) => { - if output.trim().is_empty() { - return Some(LOGIN_MSG_DESKTOP_NO_DESKTOP); - } - } - _ => { - return Some(LOGIN_MSG_DESKTOP_NO_DESKTOP); - } - } - - None -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -enum XSessionStartErrorKind { - Auth, - Env, -} - -const XSESSION_AUTH_FAILURE_DETAIL: &str = "authentication failed"; - -#[derive(Debug)] -struct XSessionStartError { - kind: XSessionStartErrorKind, - detail: String, -} - -impl XSessionStartError { - fn auth(detail: String) -> Self { - Self { - kind: XSessionStartErrorKind::Auth, - detail, - } - } - - fn env(detail: String) -> Self { - Self { - kind: XSessionStartErrorKind::Env, - detail, - } - } -} - -impl std::fmt::Display for XSessionStartError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.detail) - } -} - -fn map_xsession_start_error_to_login_msg(kind: XSessionStartErrorKind) -> &'static str { - match kind { - XSessionStartErrorKind::Auth => LOGIN_MSG_PASSWORD_WRONG, - XSessionStartErrorKind::Env => LOGIN_MSG_DESKTOP_XSESSION_FAILED, - } -} - -pub fn try_start_desktop(_username: &str, _passsword: &str) -> String { - debug_assert!(crate::is_server()); - if _username.is_empty() { - let username = get_username(); - if username.is_empty() { - if let Some(msg) = detect_headless() { - msg - } else { - LOGIN_MSG_DESKTOP_SESSION_NOT_READY - } - } else { - "" - } - .to_owned() - } else { - let username = get_username(); - log::debug!("try_start_desktop, username: {}, _username: {}", &username, &_username); - if username == _username { - // No need to verify password here. - return "".to_owned(); - } - if !username.is_empty() { - // Another user is logged in. No need to start a new xsession. - return "".to_owned(); - } - - if let Some(msg) = detect_headless() { - return msg.to_owned(); - } - - match try_start_x_session(_username, _passsword) { - Ok((username, x11_ready)) => { - if x11_ready { - if _username != username { - LOGIN_MSG_DESKTOP_SESSION_ANOTHER_USER.to_owned() - } else { - "".to_owned() - } - } else { - LOGIN_MSG_DESKTOP_SESSION_NOT_READY.to_owned() - } - } - Err(e) => { - match e.kind { - XSessionStartErrorKind::Auth => { - log::warn!("Failed to authenticate xsession user {}", e); - } - XSessionStartErrorKind::Env => { - log::error!("Failed to start xsession {}", e); - } - } - map_xsession_start_error_to_login_msg(e.kind).to_owned() - } - } - } -} - -fn try_start_x_session(username: &str, password: &str) -> Result<(String, bool), XSessionStartError> { - // Seat0 is read BEFORE the manager lock: the lookup runs loginctl, and at a greeter the DRM - // probe, and holding DESKTOP_MANAGER across those waits serializes every other caller. - let seat0_username = refresh_seat0_snapshot(); - let mut desktop_manager = DESKTOP_MANAGER.lock().unwrap(); - if let Some(desktop_manager) = &mut (*desktop_manager) { - if let Some(seat0_username) = seat0_username { - return Ok((seat0_username, true)); - } - - let _ = desktop_manager.try_start_x_session(username, password)?; - log::debug!( - "try_start_x_session, username: {}, {:?}", - &username, - &desktop_manager - ); - Ok(( - desktop_manager.child_username.clone(), - desktop_manager.is_running(), - )) - } else { - Err(XSessionStartError::env( - crate::client::LOGIN_MSG_DESKTOP_NOT_INITED.to_owned(), - )) - } -} - -#[inline] -/// The PRE-AUTH form: connection setup asks this before the peer has authenticated, so it must -/// not run loginctl or wait on the DRM probe (an unauthenticated client would occupy a worker, -/// and every connection would serialize behind the same lookup). It answers from the last -/// settled snapshot and refreshes it off-thread; the decisions that ENFORCE — `get_username`, -/// `try_start_x_session` — stay fresh. -pub fn is_headless() -> bool { - if DESKTOP_MANAGER.lock().unwrap().is_none() { - return false; - } - let cached = SEAT0_SNAPSHOT.lock().unwrap().username.clone(); - kick_seat0_refresh(); - // No snapshot yet answers NOT headless: guessing in the headless direction would show the - // OS-login flow over a live Wayland greeter, which reads as an empty seat0 too. A false - // only delays the headless flow until the first refresh lands, and the snapshot is seeded - // from `start_xdesktop`, so the empty window is server start, not every connection. - cached.map_or(false, |answer| answer.is_none()) -} - -/// A free function on purpose: it runs loginctl (and at a greeter the DRM probe), so no caller -/// may reach it while holding `DESKTOP_MANAGER` — that mutex held across subprocess or IPC waits -/// serializes every connection behind one slow lookup. -fn supported_display_seat0_username() -> Option { - // Read seat0 fresh on every query: the values cached in `DesktopManager::new()` go stale - // across a logout or fast-user-switch, which would skip the greeter probe below and hand - // back the previous session owner. Queried here and not in `new()` also because the read - // there hides greeters. - let seat0_values = get_values_of_seat0(&[0, 2]); - let seat0_username = seat0_values[1].clone(); - #[cfg(feature = "drm")] - if seat0_username.is_empty() || is_gdm_user(&seat0_username) { - if let Some(username) = drm_login_screen_seat0_username() { - return Some(username); - } - } - if seat0_username.is_empty() { - None - } else if is_gdm_user(&seat0_username) - && get_display_server_of_session(&seat0_values[0]) == DISPLAY_SERVER_WAYLAND - { - None - } else { - Some(seat0_username) - } -} - -fn select_newer_seat0_snapshot(current: Seat0Snapshot, candidate: Seat0Snapshot) -> Seat0Snapshot { - if candidate.sequence > current.sequence { - candidate - } else { - current - } -} - -fn refresh_seat0_snapshot() -> Option { - let sequence = SEAT0_QUERY_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let fresh = supported_display_seat0_username(); - let candidate = Seat0Snapshot { - sequence, - username: Some(fresh.clone()), - }; - let mut snapshot = SEAT0_SNAPSHOT.lock().unwrap(); - let current = std::mem::take(&mut *snapshot); - *snapshot = select_newer_seat0_snapshot(current, candidate); - fresh -} - -/// Clears the single-flight flag on every exit, including a panic in the refresh thread; without -/// it a panic would freeze `is_headless` on a stale snapshot for the process lifetime. -struct Seat0RefreshGuard; -impl Drop for Seat0RefreshGuard { - fn drop(&mut self) { - SEAT0_REFRESH_IN_FLIGHT.store(false, Ordering::Release); - } -} - -/// Refresh the snapshot off-thread with a process-wide rate limit and single-flight. -fn kick_seat0_refresh() { - let now = Instant::now(); - { - let mut next_refresh = SEAT0_NEXT_REFRESH.lock().unwrap(); - let (next, should_refresh) = schedule_seat0_refresh(*next_refresh, now); - *next_refresh = next; - if !should_refresh { - return; - } - } - if SEAT0_REFRESH_IN_FLIGHT.swap(true, Ordering::AcqRel) { - return; - } - let guard = Seat0RefreshGuard; - if let Err(err) = std::thread::Builder::new() - .name("seat0-snapshot".into()) - .spawn(move || { - let _guard = guard; - let _ = refresh_seat0_snapshot(); - }) - { - log::warn!("Could not spawn the seat0 snapshot refresh thread: {err}"); - } -} - -/// The Wayland greeter on seat0, if the DRM backend can capture and inject into it. -#[cfg(feature = "drm")] -fn drm_login_screen_seat0_username() -> Option { - // An operator-forced X11 wins over greeter adoption: adopting would rebuild exactly the - // inconsistency the forced gate exists to prevent — a session admitted for DRM serving - // while capture and input route down the X11 path. - if crate::platform::linux::display_server_forced() && crate::platform::linux::is_x11() { - return None; - } - let values = get_values_of_seat0_with_gdm_wayland(&[0, 2]); - if !is_gdm_user(&values[1]) - || get_display_server_of_session(&values[0]) != DISPLAY_SERVER_WAYLAND - { - return None; - } - // The cached tri-state, never the probing form: this runs on the unauthenticated login path, - // so it must not wait out a probe deadline. Only a definitive unavailable hands the seat to - // X11; an unsettled result keeps the maybe-live greeter (settling happens off-thread). - if crate::server::drm_capturer::availability_cached() - == crate::server::drm_capturer::Availability::Unavailable - { - return None; - } - Some(values[1].clone()) -} - -fn cached_username_from_state( - seat0_username: Option, - managed_session: Option<(&str, bool)>, -) -> String { - if let Some(username) = seat0_username { - return username; - } - match managed_session { - Some((username, true)) => username.to_owned(), - _ => String::new(), - } -} - -fn schedule_seat0_refresh(next_refresh: Option, now: Instant) -> (Option, bool) { - if next_refresh.is_some_and(|deadline| now < deadline) { - return (next_refresh, false); - } - (Some(now + SEAT0_REFRESH_INTERVAL), true) -} - -/// Returns the last settled username without running external commands. -pub fn get_cached_username() -> String { - let seat0_username = SEAT0_SNAPSHOT.lock().unwrap().username.clone().flatten(); - let username = { - let manager = DESKTOP_MANAGER.lock().unwrap(); - let Some(manager) = manager.as_ref() else { - return String::new(); - }; - cached_username_from_state( - seat0_username, - Some((&manager.child_username, manager.is_running())), - ) - }; - if username.is_empty() { - kick_seat0_refresh(); - } - username -} - -pub fn get_username() -> String { - if DESKTOP_MANAGER.lock().unwrap().is_none() { - return "".to_owned(); - } - // Computed with the manager lock RELEASED: the lookup runs loginctl, and at a greeter the - // DRM probe, and holding DESKTOP_MANAGER across those waits serializes every caller behind - // one slow probe. - if let Some(seat0_username) = refresh_seat0_snapshot() { - return seat0_username; - } - match &*DESKTOP_MANAGER.lock().unwrap() { - Some(manager) => { - if manager.is_running() && !manager.child_username.is_empty() { - manager.child_username.clone() - } else { - "".to_owned() - } - } - None => "".to_owned(), - } -} - -impl Drop for DesktopManager { - fn drop(&mut self) { - self.stop_children(); - } -} - -impl DesktopManager { - fn fatal_exit() { - std::process::exit(0); - } - - pub fn new() -> Self { - Self { - child_username: "".to_owned(), - child_exit: Arc::new(AtomicBool::new(true)), - is_child_running: Arc::new(AtomicBool::new(false)), - } - } - - #[inline] - fn get_xauth() -> String { - let xauth = get_env_var("XAUTHORITY"); - if xauth.is_empty() { - "/tmp/.Xauthority".to_owned() - } else { - xauth - } - } - - #[inline] - fn is_running(&self) -> bool { - self.is_child_running.load(Ordering::SeqCst) - } - - fn try_start_x_session( - &mut self, - username: &str, - password: &str, - ) -> Result<(), XSessionStartError> { - match get_user_by_name(username) { - Some(userinfo) => { - let mut client = pam::Client::with_password(&pam_get_service_name()) - .map_err(|e| XSessionStartError::env(format!("failed to init pam client, {}", e)))?; - client - .conversation_mut() - .set_credentials(username, password); - match client.authenticate() { - Ok(_) => { - if self.is_running() { - return Ok(()); - } - - match self.start_x_session(&userinfo, username, password) { - Ok(_) => { - log::info!("Succeeded to start x11"); - self.child_username = username.to_string(); - Ok(()) - } - Err(e) => { - Err(XSessionStartError::env(format!( - "failed to start x session, {}", - e - ))) - } - } - } - Err(_e) => { - Err(XSessionStartError::auth( - XSESSION_AUTH_FAILURE_DETAIL.to_owned(), - )) - } - } - } - None => { - Err(XSessionStartError::auth( - XSESSION_AUTH_FAILURE_DETAIL.to_owned(), - )) - } - } - } - - // The logic mainly from https://github.com/neutrinolabs/xrdp/blob/34fe9b60ebaea59e8814bbc3ca5383cabaa1b869/sesman/session.c#L334. - fn get_avail_display() -> ResultType { - let display_range = 0..51; - for i in display_range.clone() { - if Self::is_x_server_running(i) { - continue; - } - return Ok(i); - } - bail!("No available display found in range {:?}", display_range) - } - - #[inline] - fn is_x_server_running(display: u32) -> bool { - Path::new(&format!("/tmp/.X11-unix/X{}", display)).exists() - || Path::new(&format!("/tmp/.X{}-lock", display)).exists() - } - - fn start_x_session( - &mut self, - userinfo: &User, - username: &str, - password: &str, - ) -> ResultType<()> { - self.stop_children(); - - let display_num = Self::get_avail_display()?; - // "xServer_ip:display_num.screen_num" - - let uid = userinfo.uid(); - let gid = userinfo.primary_group_id(); - let envs = HashMap::from([ - ("SHELL", userinfo.shell().to_string_lossy().to_string()), - ("PATH", "/sbin:/bin:/usr/bin:/usr/local/bin".to_owned()), - ("USER", username.to_string()), - ("UID", userinfo.uid().to_string()), - ("HOME", userinfo.home_dir().to_string_lossy().to_string()), - ( - "XDG_RUNTIME_DIR", - format!("/run/user/{}", userinfo.uid().to_string()), - ), - // ("DISPLAY", self.display.clone()), - // ("XAUTHORITY", self.xauth.clone()), - // (ENV_DESKTOP_PROTOCOL, XProtocol::X11.to_string()), - ]); - self.child_exit.store(false, Ordering::SeqCst); - let is_child_running = self.is_child_running.clone(); - - let (tx_res, rx_res) = sync_channel(1); - let password = password.to_string(); - let username = username.to_string(); - // start x11 - std::thread::spawn(move || { - match Self::start_x_session_thread( - tx_res.clone(), - is_child_running, - uid, - gid, - display_num, - username, - password, - envs, - ) { - Ok(_) => {} - Err(e) => { - log::error!("Failed to start x session thread"); - allow_err!(tx_res.send(format!("Failed to start x session thread, {}", e))); - } - } - }); - - // wait x11 - match rx_res.recv_timeout(Duration::from_millis(10_000)) { - Ok(res) => { - if res == "" { - Ok(()) - } else { - bail!(res) - } - } - Err(e) => { - bail!("Failed to recv x11 result {}", e) - } - } - } - - #[inline] - fn display_from_num(num: u32) -> String { - format!(":{num}") - } - - fn start_x_session_thread( - tx_res: SyncSender, - is_child_running: Arc, - uid: u32, - gid: u32, - display_num: u32, - username: String, - password: String, - envs: HashMap<&str, String>, - ) -> ResultType<()> { - let mut client = pam::Client::with_password(&pam_get_service_name())?; - client - .conversation_mut() - .set_credentials(&username, &password); - client.authenticate()?; - - client.set_item(pam::PamItemType::TTY, &Self::display_from_num(display_num))?; - client.open_session()?; - - // fixme: FreeBSD kernel needs to login here. - // see: https://github.com/neutrinolabs/xrdp/blob/a64573b596b5fb07ca3a51590c5308d621f7214e/sesman/session.c#L556 - - let (child_xorg, child_wm) = Self::start_x11(uid, gid, username, display_num, &envs)?; - is_child_running.store(true, Ordering::SeqCst); - - // capture the logind session scope (from a live child) for teardown and crash - // recovery, see reap_session_scope and recover_orphaned_session. - let scope_dir = Self::session_scope_dir(child_xorg.id()); - Self::save_orphaned_marker(&scope_dir, display_num); - - log::info!("Start xorg and wm done, notify and wait xtop x11"); - allow_err!(tx_res.send("".to_owned())); - - Self::wait_stop_x11(child_xorg, child_wm, scope_dir, display_num); - log::info!("Wait x11 stop done"); - Ok(()) - } - - fn wait_xorg_exit(child_xorg: &mut Child) -> ResultType { - if let Ok(_) = child_xorg.kill() { - for _ in 0..3 { - match child_xorg.try_wait() { - Ok(Some(status)) => return Ok(format!("Xorg exit with {}", status)), - Ok(None) => {} - Err(e) => { - // fatal error - log::error!("Failed to wait xorg process, {}", e); - bail!("Failed to wait xorg process, {}", e) - } - } - std::thread::sleep(std::time::Duration::from_millis(1_000)); - } - log::error!("Failed to wait xorg process, not exit"); - bail!("Failed to wait xorg process, not exit") - } else { - Ok("Xorg is already exited".to_owned()) - } - } - - fn add_xauth_cookie( - file: &str, - display: &str, - uid: u32, - gid: u32, - envs: &HashMap<&str, String>, - ) -> ResultType<()> { - let randstr = (0..16) - .map(|_| format!("{:02x}", random::())) - .collect::(); - let output = Command::new("xauth") - .uid(uid) - .gid(gid) - .envs(envs) - .args(vec!["-q", "-f", file, "add", display, ".", &randstr]) - .output()?; - // xauth run success, even the following error occurs. - // Ok(Output { status: ExitStatus(unix_wait_status(0)), stdout: "", stderr: "xauth: file .Xauthority does not exist\n" }) - let errmsg = String::from_utf8_lossy(&output.stderr).to_string(); - if !errmsg.is_empty() { - if !errmsg.contains("does not exist") { - bail!("Failed to launch xauth, {}", errmsg) - } - } - Ok(()) - } - - fn wait_x_server_running(pid: u32, display_num: u32, max_wait_secs: u64) -> ResultType<()> { - let wait_begin = Instant::now(); - loop { - if run_cmds(&format!("ls /proc/{}", pid))?.is_empty() { - bail!("X server exit"); - } - - if Self::is_x_server_running(display_num) { - return Ok(()); - } - if wait_begin.elapsed().as_secs() > max_wait_secs { - bail!("Failed to wait xserver after {} seconds", max_wait_secs); - } - std::thread::sleep(Duration::from_millis(300)); - } - } - - fn start_x11( - uid: u32, - gid: u32, - username: String, - display_num: u32, - envs: &HashMap<&str, String>, - ) -> ResultType<(Child, Child)> { - log::debug!("envs of user {}: {:?}", &username, &envs); - - let xauth = Self::get_xauth(); - let display = Self::display_from_num(display_num); - - Self::add_xauth_cookie(&xauth, &display, uid, gid, &envs)?; - - // Start Xorg - let mut child_xorg = Self::start_x_server(&xauth, &display, uid, gid, &envs)?; - - log::info!("xorg started, wait 10 secs to ensuer x server is running"); - - let max_wait_secs = 10; - // wait x server running - if let Err(e) = Self::wait_x_server_running(child_xorg.id(), display_num, max_wait_secs) { - match Self::wait_xorg_exit(&mut child_xorg) { - Ok(msg) => log::info!("{}", msg), - Err(e) => { - log::error!("{}", e); - Self::fatal_exit(); - } - } - bail!(e) - } - - log::info!( - "xorg is running, start x window manager with DISPLAY: {}, XAUTHORITY: {}", - &display, - &xauth - ); - - std::env::set_var("DISPLAY", &display); - std::env::set_var("XAUTHORITY", &xauth); - // start window manager (startwm.sh) - let child_wm = match Self::start_x_window_manager(uid, gid, &envs) { - Ok(c) => c, - Err(e) => { - match Self::wait_xorg_exit(&mut child_xorg) { - Ok(msg) => log::info!("{}", msg), - Err(e) => { - log::error!("{}", e); - Self::fatal_exit(); - } - } - bail!(e) - } - }; - log::info!("x window manager is started"); - - Ok((child_xorg, child_wm)) - } - - fn try_wait_x11_child_exit(child_xorg: &mut Child, child_wm: &mut Child) -> bool { - match child_xorg.try_wait() { - Ok(Some(status)) => { - log::info!("Xorg exit with {}", status); - return true; - } - Ok(None) => {} - Err(e) => log::error!("Failed to wait xorg process, {}", e), - } - - match child_wm.try_wait() { - Ok(Some(status)) => { - // Logout may result "wm exit with signal: 11 (SIGSEGV) (core dumped)" - log::info!("wm exit with {}", status); - return true; - } - Ok(None) => {} - Err(e) => log::error!("Failed to wait xorg process, {}", e), - } - false - } - - fn wait_x11_children_exit(child_xorg: &mut Child, child_wm: &mut Child) { - log::debug!("Try kill child process xorg"); - if let Ok(_) = child_xorg.kill() { - let mut exited = false; - for _ in 0..2 { - match child_xorg.try_wait() { - Ok(Some(status)) => { - log::info!("Xorg exit with {}", status); - exited = true; - break; - } - Ok(None) => {} - Err(e) => { - log::error!("Failed to wait xorg process, {}", e); - Self::fatal_exit(); - } - } - std::thread::sleep(std::time::Duration::from_millis(1_000)); - } - if !exited { - log::error!("Failed to wait child xorg, after kill()"); - // try kill -9? - } - } - log::debug!("Try kill child process wm"); - if let Ok(_) = child_wm.kill() { - let mut exited = false; - for _ in 0..2 { - match child_wm.try_wait() { - Ok(Some(status)) => { - // Logout may result "wm exit with signal: 11 (SIGSEGV) (core dumped)" - log::info!("wm exit with {}", status); - exited = true; - } - Ok(None) => {} - Err(e) => { - log::error!("Failed to wait wm process, {}", e); - Self::fatal_exit(); - } - } - std::thread::sleep(std::time::Duration::from_millis(1_000)); - } - if !exited { - log::error!("Failed to wait child xorg, after kill()"); - // try kill -9? - } - } - } - - // resolve the "session-.scope" directory pam_systemd put the x session in, read - // from a live child pid. cgroup v2 mounts every cgroup under /sys/fs/cgroup, v1/hybrid - // keeps the scope under the systemd controller mount; pick by the controller field and - // confirm the cgroup is real. empty if there is no such scope (e.g. no logind). - fn session_scope_dir(pid: u32) -> String { - let path = format!("/proc/{}/cgroup", pid); - let content = match std::fs::read_to_string(&path) { - Ok(c) => c, - Err(e) => { - log::warn!("Failed to read {} to find session scope: {}", path, e); - return "".to_owned(); - } - }; - for line in content.lines() { - // "::"; v2 unified is "0::", the v1 - // systemd hierarchy is ":name=systemd:". - let mut fields = line.splitn(3, ':'); - let (controllers, cgroup) = match (fields.next(), fields.next(), fields.next()) { - (Some(_), Some(c), Some(p)) => (c, p), - _ => continue, - }; - let scope = match Self::session_scope(cgroup) { - Some(s) => s, - None => continue, - }; - let mount = if controllers.is_empty() { - "/sys/fs/cgroup" - } else if controllers.split(',').any(|c| c == "name=systemd") { - "/sys/fs/cgroup/systemd" - } else { - continue; - }; - let dir = format!("{}{}", mount, scope); - if Path::new(&format!("{}/cgroup.procs", dir)).exists() { - return dir; - } - } - "".to_owned() - } - - // the "/.../session-.scope" prefix of a cgroup path, dropping any nested child - // cgroup below it so a descendant scope does not get mistaken for the session. - fn session_scope(cgroup: &str) -> Option { - let mut scope = String::new(); - for comp in cgroup.split('/').filter(|c| !c.is_empty()) { - scope.push('/'); - scope.push_str(comp); - if comp.starts_with("session-") && comp.ends_with(".scope") { - return Some(scope); - } - } - None - } - - // on teardown reap the whole session scope subtree, not just the xorg + wm pids: - // the per-session pipewire and other desktop children otherwise outlive them and - // hold the logind session in "closing", leaking sockets + displays on reconnect - // (rustdesk/rustdesk#15183). SIGTERM first so pipewire unlinks its sockets, then - // SIGKILL stragglers; skip our own pid (pam put the service in the scope too). - fn reap_session_scope(scope_dir: &str) { - if scope_dir.is_empty() { - return; - } - let me = std::process::id(); - // spare the --server's own children and any descendants of them sharing this scope - // (see pid_is_spared); only the desktop session's leftovers are reaped. - let spared: Vec = crate::server::CHILD_PROCESS - .lock() - .unwrap() - .iter() - .map(|c| c.id()) - .collect(); - for sig in [hbb_common::libc::SIGTERM, hbb_common::libc::SIGKILL] { - let mut pids = Vec::new(); - Self::collect_scope_pids(Path::new(scope_dir), &mut pids); - let mut any = false; - for pid in pids { - if pid == me || Self::pid_is_spared(pid, &spared, me) { - continue; - } - any = true; - log::info!("Reaping leftover session process {} (signal {})", pid, sig); - unsafe { - if hbb_common::libc::kill(pid as hbb_common::libc::pid_t, sig) != 0 { - let err = std::io::Error::last_os_error(); - // ESRCH = it already exited (or did between snapshot and now). - if err.raw_os_error() != Some(hbb_common::libc::ESRCH) { - log::warn!("Failed to signal session process {}: {}", pid, err); - } - } - } - } - if !any { - break; - } - if sig == hbb_common::libc::SIGTERM { - std::thread::sleep(Duration::from_millis(300)); - } - } - } - - // a tracked --server child (the sudo wrapper run_as_user spawns) or any descendant of - // one: with use_pty sudo runs --cm-no-ui under a monitor with its own pid, so walk the - // parent chain (stopping at the --server) to spare the worker, not just the wrapper. - fn pid_is_spared(pid: u32, spared: &[u32], me: u32) -> bool { - let mut cur = pid; - for _ in 0..32 { - if spared.contains(&cur) { - return true; - } - if cur <= 1 || cur == me { - return false; - } - match Self::parent_pid(cur) { - Some(ppid) => cur = ppid, - None => return false, - } - } - false - } - - fn parent_pid(pid: u32) -> Option { - // /proc//stat is "pid (comm) state ppid ..."; comm can contain spaces and ')', - // so read the fields after the last ')'. - let stat = std::fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?; - stat.rsplit_once(')')? - .1 - .split_whitespace() - .nth(1)? - .parse() - .ok() - } - - // collect every pid in the cgroup subtree rooted at dir. "cgroup.procs" lists only - // the procs directly in a cgroup, so recurse into child cgroup directories to catch - // processes the desktop session moved into descendant scopes. - fn collect_scope_pids(dir: &Path, out: &mut Vec) { - let procs = dir.join("cgroup.procs"); - match std::fs::read_to_string(&procs) { - Ok(content) => { - out.extend(content.lines().filter_map(|l| l.trim().parse::().ok())); - } - Err(e) if e.kind() != std::io::ErrorKind::NotFound => { - log::warn!("Failed to read {}: {}", procs.display(), e); - } - Err(_) => {} - } - let entries = match std::fs::read_dir(dir) { - Ok(e) => e, - Err(e) if e.kind() != std::io::ErrorKind::NotFound => { - log::warn!("Failed to list cgroup dir {}: {}", dir.display(), e); - return; - } - Err(_) => return, - }; - for entry in entries { - let entry = match entry { - Ok(entry) => entry, - Err(e) => { - log::warn!("Failed to read entry under {}: {}", dir.display(), e); - continue; - } - }; - match entry.file_type() { - Ok(t) if t.is_dir() => Self::collect_scope_pids(&entry.path(), out), - Ok(_) => {} - Err(e) if e.kind() != std::io::ErrorKind::NotFound => { - log::warn!("Failed to stat {}: {}", entry.path().display(), e); - } - Err(_) => {} - } - } - } - - // a SIGKILL'd Xorg (how wait_x11_children_exit ends it) leaves "/tmp/.X-lock" and - // "/tmp/.X11-unix/X" behind, and get_avail_display() treats either file as "display - // in use", so the number is never reused and climbs until none are free - // (rustdesk/rustdesk#15183). a clean exit would remove them; do the same on teardown, - // but skip it if a live process still holds the lock: another server could have taken - // the number in the gap, and removing its files would break that display. - fn cleanup_x_display_files(display_num: u32) { - let lock = format!("/tmp/.X{}-lock", display_num); - if let Ok(content) = std::fs::read_to_string(&lock) { - if let Ok(pid) = content.trim().parse::() { - if Self::pid_alive(pid) { - log::info!("X display {} still held by pid {}, leaving its files", display_num, pid); - return; - } - } - } - for path in [lock, format!("/tmp/.X11-unix/X{}", display_num)] { - if let Err(e) = std::fs::remove_file(&path) { - if e.kind() != std::io::ErrorKind::NotFound { - log::warn!("Failed to remove stale X file {}: {}", path, e); - } - } - } - } - - // signal-0 probe: the pid exists if kill succeeds or fails with EPERM (alive but not - // ours); only ESRCH means it is gone. - fn pid_alive(pid: i32) -> bool { - unsafe { - if hbb_common::libc::kill(pid as hbb_common::libc::pid_t, 0) == 0 { - return true; - } - } - std::io::Error::last_os_error().raw_os_error() == Some(hbb_common::libc::EPERM) - } - - const ORPHANED_SESSION_KEY: &'static str = "headless-orphaned-session"; - - fn save_orphaned_marker(scope_dir: &str, display_num: u32) { - // tag the marker with this boot's id: a logind session id is only unique within a - // boot (the counter lives in /run and resets), so recovery must not reap a recorded - // scope path after a reboot, when it may name a different live session. - let boot_id = Self::current_boot_id().unwrap_or_default(); - hbb_common::config::LocalConfig::set_option( - Self::ORPHANED_SESSION_KEY.to_owned(), - format!("{};{};{}", scope_dir, display_num, boot_id), - ); - } - - fn current_boot_id() -> Option { - std::fs::read_to_string("/proc/sys/kernel/random/boot_id") - .ok() - .map(|s| s.trim().to_owned()) - } - - fn clear_orphaned_marker() { - hbb_common::config::LocalConfig::set_option( - Self::ORPHANED_SESSION_KEY.to_owned(), - String::new(), - ); - } - - fn parse_orphaned_marker(marker: &str) -> Option<(&str, u32, &str)> { - let (rest, boot_id) = marker.rsplit_once(';')?; - let (scope_dir, display) = rest.rsplit_once(';')?; - Some((scope_dir, display.trim().parse::().ok()?, boot_id)) - } - - // a run that dies before wait_stop_x11 (service or --server crash) leaks the headless - // session scope + X lock files, the same as a missed teardown (rustdesk/rustdesk#15183). - // reap exactly what the dead run recorded - never a scan, so unrelated sessions are safe. - fn recover_orphaned_session() { - let marker = hbb_common::config::LocalConfig::get_option(Self::ORPHANED_SESSION_KEY); - if marker.is_empty() { - return; - } - if let Some((scope_dir, display_num, boot_id)) = Self::parse_orphaned_marker(&marker) { - // only reap the recorded scope when the marker is from this same boot: a leaked - // cgroup cannot outlive a reboot, so cross-boot there is nothing legitimate to - // reap, and the recorded "session-N.scope" may by then name a different live - // session. the X lock cleanup is pid-guarded, so run it either way. - let same_boot = Self::current_boot_id().map_or(false, |b| b == boot_id); - log::info!( - "Recovering leaked headless session from a previous run: scope {}, display {} (same boot: {})", - scope_dir, - display_num, - same_boot - ); - if same_boot { - Self::reap_session_scope(scope_dir); - } - Self::cleanup_x_display_files(display_num); - } - Self::clear_orphaned_marker(); - } - - fn try_wait_stop_x11( - child_xorg: &mut Child, - child_wm: &mut Child, - scope_dir: &str, - display_num: u32, - ) -> bool { - let mut desktop_manager = DESKTOP_MANAGER.lock().unwrap(); - let mut exited = true; - if let Some(desktop_manager) = &mut (*desktop_manager) { - if desktop_manager.child_exit.load(Ordering::SeqCst) { - exited = true; - } else { - exited = Self::try_wait_x11_child_exit(child_xorg, child_wm); - } - if exited { - log::debug!("Wait x11 children exiting"); - Self::wait_x11_children_exit(child_xorg, child_wm); - Self::reap_session_scope(scope_dir); - Self::cleanup_x_display_files(display_num); - Self::clear_orphaned_marker(); - desktop_manager - .is_child_running - .store(false, Ordering::SeqCst); - desktop_manager.child_exit.store(true, Ordering::SeqCst); - } - } - exited - } - - fn wait_stop_x11( - mut child_xorg: Child, - mut child_wm: Child, - scope_dir: String, - display_num: u32, - ) { - loop { - if Self::try_wait_stop_x11(&mut child_xorg, &mut child_wm, &scope_dir, display_num) { - break; - } - std::thread::sleep(Duration::from_millis(super::SERVICE_INTERVAL)); - } - } - - fn get_xorg() -> &'static str { - // Fedora 26 or later - let xorg = "/usr/libexec/Xorg"; - if Path::new(xorg).is_file() { - return xorg; - } - // Debian 9 or later - let xorg = "/usr/lib/xorg/Xorg"; - if Path::new(xorg).is_file() { - return xorg; - } - // Ubuntu 16.04 or later - let xorg = "/usr/lib/xorg/Xorg"; - if Path::new(xorg).is_file() { - return xorg; - } - // Arch Linux - let xorg = "/usr/lib/xorg-server/Xorg"; - if Path::new(xorg).is_file() { - return xorg; - } - // Arch Linux - let xorg = "/usr/lib/Xorg"; - if Path::new(xorg).is_file() { - return xorg; - } - // CentOS 7 /usr/bin/Xorg or param=Xorg - - log::warn!("Failed to find xorg, use default Xorg.\n Please add \"allowed_users=anybody\" to \"/etc/X11/Xwrapper.config\"."); - "Xorg" - } - - fn start_x_server( - xauth: &str, - display: &str, - uid: u32, - gid: u32, - envs: &HashMap<&str, String>, - ) -> ResultType { - let xorg = Self::get_xorg(); - log::info!("Use xorg: {}", &xorg); - let app_name = crate::get_app_name().to_lowercase(); - let conf = format!("/etc/{app_name}/xorg.conf"); - match Command::new(xorg) - .envs(envs) - .uid(uid) - .gid(gid) - .args(vec![ - "-noreset", - "+extension", - "GLX", - "+extension", - "RANDR", - "+extension", - "RENDER", - "-config", - conf.as_ref(), - "-auth", - xauth, - display, - ]) - .spawn() - { - Ok(c) => Ok(c), - Err(e) => { - bail!("Failed to start Xorg with display {}, {}", display, e); - } - } - } - - fn start_x_window_manager( - uid: u32, - gid: u32, - envs: &HashMap<&str, String>, - ) -> ResultType { - let app_name = crate::get_app_name().to_lowercase(); - match Command::new(&format!("/etc/{app_name}/startwm.sh")) - .envs(envs) - .uid(uid) - .gid(gid) - .spawn() - { - Ok(c) => Ok(c), - Err(e) => { - bail!("Failed to start window manager, {}", e); - } - } - } - - fn stop_children(&mut self) { - self.child_exit.store(true, Ordering::SeqCst); - for _i in 1..10 { - if !self.is_child_running.load(Ordering::SeqCst) { - break; - } - std::thread::sleep(Duration::from_millis(super::SERVICE_INTERVAL)); - } - if self.is_child_running.load(Ordering::SeqCst) { - log::warn!("xdesktop child is still running!"); - } - } -} - -fn pam_get_service_name() -> String { - let app_name = crate::get_app_name().to_lowercase(); - if Path::new(&format!("/etc/pam.d/{app_name}")).is_file() { - app_name - } else { - "gdm".to_owned() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cached_username_prefers_seat0_and_running_managed_session() { - assert_eq!( - cached_username_from_state(Some("seat0".to_owned()), Some(("managed", true))), - "seat0" - ); - assert_eq!( - cached_username_from_state(None, Some(("managed", true))), - "managed" - ); - assert_eq!( - cached_username_from_state(None, Some(("managed", false))), - "" - ); - assert_eq!(cached_username_from_state(None, None), ""); - } - - #[test] - fn seat0_refresh_schedule_limits_process_wide_rate() { - let started = Instant::now(); - let (next_refresh, should_refresh) = schedule_seat0_refresh(None, started); - assert!(should_refresh); - - let (unchanged, should_refresh) = schedule_seat0_refresh(next_refresh, started); - assert!(!should_refresh); - assert_eq!(unchanged, next_refresh); - - let (_, should_refresh) = - schedule_seat0_refresh(next_refresh, started + SEAT0_REFRESH_INTERVAL); - assert!(should_refresh); - } - - #[test] - fn session_scope_truncates_at_first_scope() { - assert_eq!( - DesktopManager::session_scope("/user.slice/user-1000.slice/session-3.scope").as_deref(), - Some("/user.slice/user-1000.slice/session-3.scope") - ); - // a nested child scope must not be mistaken for the session - assert_eq!( - DesktopManager::session_scope( - "/user.slice/user-1000.slice/session-3.scope/app-foo.scope" - ) - .as_deref(), - Some("/user.slice/user-1000.slice/session-3.scope") - ); - assert_eq!( - DesktopManager::session_scope( - "/user.slice/user-1000.slice/user@1000.service/app.slice/x.service" - ), - None - ); - assert_eq!(DesktopManager::session_scope("/"), None); - } - - #[test] - fn collect_scope_pids_walks_descendant_cgroups() { - // regression for #15183: pids in descendant cgroups must be collected too - let base = std::env::temp_dir().join(format!("rustdesk-cgtest-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - let scope = base.join("session-3.scope"); - let child = scope.join("app-foo.scope"); - let nested = child.join("deeper.scope"); - std::fs::create_dir_all(&nested).unwrap(); - std::fs::create_dir_all(scope.join("empty.scope")).unwrap(); - std::fs::write(scope.join("cgroup.procs"), "100\n101\n").unwrap(); - std::fs::write(scope.join("cgroup.controllers"), "memory pids\n").unwrap(); - std::fs::write(child.join("cgroup.procs"), "200\n").unwrap(); - std::fs::write(nested.join("cgroup.procs"), "300\n").unwrap(); - - let mut pids = Vec::new(); - DesktopManager::collect_scope_pids(&scope, &mut pids); - pids.sort(); - let _ = std::fs::remove_dir_all(&base); - - assert_eq!(pids, vec![100, 101, 200, 300]); - } - - #[test] - fn parses_orphaned_session_marker() { - assert_eq!( - DesktopManager::parse_orphaned_marker( - "/sys/fs/cgroup/user.slice/user-1000.slice/session-3.scope;7;abc-123" - ), - Some(( - "/sys/fs/cgroup/user.slice/user-1000.slice/session-3.scope", - 7, - "abc-123" - )) - ); - // an empty scope still carries the display so its stale X lock can be cleaned - assert_eq!(DesktopManager::parse_orphaned_marker(";5;abc-123"), Some(("", 5, "abc-123"))); - // an empty boot id never matches the live one, so the scope reap is skipped - assert_eq!(DesktopManager::parse_orphaned_marker("/scope;5;"), Some(("/scope", 5, ""))); - assert_eq!(DesktopManager::parse_orphaned_marker(""), None); - assert_eq!(DesktopManager::parse_orphaned_marker("garbage"), None); - // the pre-boot-id two-field format no longer parses, recovery just skips it - assert_eq!(DesktopManager::parse_orphaned_marker("/scope;7"), None); - assert_eq!(DesktopManager::parse_orphaned_marker("/scope;notnum;abc"), None); - } -} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index c1bc38232..d55005b4d 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -20,9 +20,6 @@ pub mod delegate; #[cfg(target_os = "linux")] pub mod linux; -#[cfg(target_os = "linux")] -pub mod linux_desktop_manager; - #[cfg(target_os = "linux")] pub mod gtk_sudo; diff --git a/src/rendezvous_mediator.rs b/src/rendezvous_mediator.rs index 4f5c8fee8..21a7e23f4 100644 --- a/src/rendezvous_mediator.rs +++ b/src/rendezvous_mediator.rs @@ -143,11 +143,6 @@ impl RendezvousMediator { allow_err!(super::lan::start_listening()); }); } - // It is ok to run xdesktop manager when the headless function is not allowed. - #[cfg(target_os = "linux")] - if crate::is_server() { - crate::platform::linux_desktop_manager::start_xdesktop(); - } scrap::codec::test_av1(); *LAST_NOT_DEPLOYED_REGISTER.lock().await = None; loop { diff --git a/src/server/connection.rs b/src/server/connection.rs index fb7d1a2fe..649f045fc 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -12,8 +12,6 @@ use crate::clipboard::{update_clipboard, ClipboardSide}; use crate::clipboard_file::*; #[cfg(target_os = "android")] use crate::keyboard::client::map_key_to_control_key; -#[cfg(target_os = "linux")] -use crate::platform::linux_desktop_manager; #[cfg(any(target_os = "windows", target_os = "linux"))] use crate::platform::WallPaperRemover; #[cfg(windows)] @@ -117,39 +115,6 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { x == 0 } -#[cfg(target_os = "linux")] -fn should_check_linux_headless_os_auth_before_desktop_start( - is_headless_allowed: bool, - username: &str, -) -> bool { - is_headless_allowed && !username.trim().is_empty() -} - -#[cfg(target_os = "linux")] -fn linux_desktop_start_credentials( - is_headless_allowed: bool, - os_login: Option<&OSLogin>, -) -> Option<(String, String)> { - if !is_headless_allowed { - return None; - } - if let Some(os_login) = os_login.filter(|os_login| !os_login.username.trim().is_empty()) { - return Some((os_login.username.clone(), os_login.password.clone())); - } - Some((String::new(), String::new())) -} - -#[cfg(target_os = "linux")] -fn should_record_linux_headless_os_auth_failure( - is_headless_allowed: bool, - username: &str, - err_msg: &str, -) -> bool { - is_headless_allowed - && !username.trim().is_empty() - && err_msg == crate::client::LOGIN_MSG_PASSWORD_WRONG -} - #[cfg(not(any(target_os = "android", target_os = "ios")))] fn should_use_terminal_os_login_scope(is_terminal: bool, os_login_username: &str) -> bool { cfg!(target_os = "windows") && is_terminal && !os_login_username.trim().is_empty() @@ -208,8 +173,6 @@ struct Session { struct StartCmIpcPara { rx_to_cm: mpsc::UnboundedReceiver, tx_from_cm: mpsc::UnboundedSender, - rx_desktop_ready: mpsc::Receiver<()>, - tx_cm_stream_ready: mpsc::Sender<()>, } #[derive(Debug, Copy, Clone, Eq, PartialEq)] @@ -351,8 +314,6 @@ pub struct Connection { options_in_login: Option, #[cfg(not(any(target_os = "ios")))] pressed_modifiers: HashSet, - #[cfg(target_os = "linux")] - linux_headless_handle: LinuxHeadlessHandle, closed: bool, #[cfg(not(any(target_os = "android", target_os = "ios")))] start_cm_ipc_para: Option, @@ -435,14 +396,10 @@ const SESSION_TIMEOUT: Duration = Duration::from_secs(30); /// Whether the DRM backend can serve a Wayland login screen here. /// -/// The cached probe, not the blocking one: this is a routing gate. Available-only ON PURPOSE, and -/// deliberately NOT symmetric with the seat0 adoption gate: that one only starts Xorg on a -/// definitive Unavailable (never over a maybe-live greeter), while admission only accepts on a -/// definitive Available (never a greeter nothing can yet capture). Both err toward refuse-and-retry -/// during an unsettled probe; admitting there would black-screen a client on a helper-less box. +/// A cold cache probes off-thread; admission still requires a definitive `Available` verdict. #[cfg(all(target_os = "linux", feature = "drm"))] fn drm_can_serve_login_screen() -> bool { - super::drm_capturer::is_available_cached() + super::drm_capturer::availability_cached() == super::drm_capturer::Availability::Available } /// Without the feature nothing can capture a Wayland greeter, so the refusal stands. @@ -484,14 +441,6 @@ impl Connection { let (tx_input, _rx_input) = std_mpsc::channel(); let (tx_from_authed, mut rx_from_authed) = mpsc::unbounded_channel::(); let mut hbbs_rx = crate::hbbs_http::sync::signal_receiver(); - #[cfg(not(any(target_os = "android", target_os = "ios")))] - let (tx_cm_stream_ready, _rx_cm_stream_ready) = mpsc::channel(1); - #[cfg(not(any(target_os = "android", target_os = "ios")))] - let (_tx_desktop_ready, rx_desktop_ready) = mpsc::channel(1); - #[cfg(target_os = "linux")] - let linux_headless_handle = - LinuxHeadlessHandle::new(_rx_cm_stream_ready, _tx_desktop_ready); - let (tx_post_seq, rx_post_seq) = mpsc::unbounded_channel(); tokio::spawn(async move { Self::post_seq_loop(rx_post_seq).await; @@ -568,15 +517,11 @@ impl Connection { options_in_login: None, #[cfg(not(any(target_os = "ios")))] pressed_modifiers: Default::default(), - #[cfg(target_os = "linux")] - linux_headless_handle, closed: false, #[cfg(not(any(target_os = "android", target_os = "ios")))] start_cm_ipc_para: Some(StartCmIpcPara { rx_to_cm, tx_from_cm, - rx_desktop_ready, - tx_cm_stream_ready, }), auto_disconnect_timer: None, authed_conn_id: None, @@ -1854,12 +1799,6 @@ impl Connection { if crate::platform::current_is_wayland() { platform_additions.insert("is_wayland".into(), json!(true)); } - #[cfg(target_os = "linux")] - if crate::platform::is_headless_allowed() { - if linux_desktop_manager::is_headless() { - platform_additions.insert("headless".into(), json!(true)); - } - } } #[cfg(target_os = "windows")] { @@ -2690,14 +2629,7 @@ impl Connection { tokio::spawn(async move { #[cfg(windows)] let tx_from_cm_clone = p.tx_from_cm.clone(); - if let Err(err) = start_ipc( - p.rx_to_cm, - p.tx_from_cm, - p.rx_desktop_ready, - p.tx_cm_stream_ready, - ) - .await - { + if let Err(err) = start_ipc(p.rx_to_cm, p.tx_from_cm).await { log::warn!("ipc to connection manager exit: {}", err); // https://github.com/rustdesk/rustdesk-server-pro/discussions/382#discussioncomment-10525725, cm may start failed #[cfg(windows)] @@ -2831,59 +2763,6 @@ impl Connection { #[cfg(not(any(target_os = "android", target_os = "ios")))] if !should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) { - #[cfg(not(target_os = "linux"))] - self.try_start_cm_ipc(); - } - - #[cfg(target_os = "linux")] - if should_check_linux_headless_os_auth_before_desktop_start( - self.linux_headless_handle.is_headless_allowed, - &lr.os_login.username, - ) { - let (_failure, res) = self.check_failure(0).await; - if !res { - return true; - } - } - - #[cfg(not(target_os = "linux"))] - let err_msg = "".to_owned(); - #[cfg(target_os = "linux")] - let err_msg = match self - .linux_headless_handle - .try_start_desktop(lr.os_login.as_ref()) - .await - { - LinuxDesktopStartOutcome::Finished(err_msg) => err_msg, - LinuxDesktopStartOutcome::Busy => { - self.send_login_error(crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY) - .await; - return true; - } - }; - - // If err is LOGIN_MSG_DESKTOP_SESSION_NOT_READY, just keep this msg and go on checking password. - if !err_msg.is_empty() && err_msg != crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY - { - #[cfg(target_os = "linux")] - if should_record_linux_headless_os_auth_failure( - self.linux_headless_handle.is_headless_allowed, - &lr.os_login.username, - &err_msg, - ) { - let (failure, res) = self.check_failure(0).await; - if !res { - return true; - } - self.update_failure(failure, false, 0); - } - self.send_login_error(err_msg).await; - return true; - } - - #[cfg(target_os = "linux")] - if !should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) { - // In headless mode, the desktop check above settles the snapshot used by CM routing. self.try_start_cm_ipc(); } @@ -2930,33 +2809,19 @@ impl Connection { } return true; } else if self.is_recent_session(false) { - if err_msg.is_empty() { - #[cfg(target_os = "linux")] - self.linux_headless_handle.wait_desktop_cm_ready().await; - if !self.send_logon_response_and_keep_alive().await { - return false; - } - self.try_start_cm(lr.my_id.clone(), lr.my_name.clone(), self.authorized); - } else { - self.send_login_error(err_msg).await; + if !self.send_logon_response_and_keep_alive().await { + return false; } + self.try_start_cm(lr.my_id.clone(), lr.my_name.clone(), self.authorized); } else if lr.password.is_empty() { - if err_msg.is_empty() { - #[cfg(not(any(target_os = "android", target_os = "ios")))] - if should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) { - if let Some(keep_alive) = - self.prepare_terminal_login_for_authorization().await - { - return keep_alive; - } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + if should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) { + if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await + { + return keep_alive; } - self.try_start_cm(lr.my_id, lr.my_name, false); - } else { - self.send_login_error( - crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY_PASSWORD_EMPTY, - ) - .await; } + self.try_start_cm(lr.my_id, lr.my_name, false); } else { let (failure, res) = self.check_failure(0).await; if !res { @@ -2965,28 +2830,15 @@ impl Connection { if !self.validate_password(allow_logon_screen_password) { self.update_failure_with_scope(failure, false, 0, FailureScope::Default); self.check_update_temporary_password(false); - if err_msg.is_empty() { - self.send_login_error(crate::client::LOGIN_MSG_PASSWORD_WRONG) - .await; - self.try_start_cm(lr.my_id, lr.my_name, false); - } else { - self.send_login_error( - crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY_PASSWORD_WRONG, - ) + self.send_login_error(crate::client::LOGIN_MSG_PASSWORD_WRONG) .await; - } + self.try_start_cm(lr.my_id, lr.my_name, false); } else { self.update_failure_with_scope(failure, true, 0, FailureScope::Default); - if err_msg.is_empty() { - #[cfg(target_os = "linux")] - self.linux_headless_handle.wait_desktop_cm_ready().await; - if !self.send_logon_response_and_keep_alive().await { - return false; - } - self.try_start_cm(lr.my_id, lr.my_name, self.authorized); - } else { - self.send_login_error(err_msg).await; + if !self.send_logon_response_and_keep_alive().await { + return false; } + self.try_start_cm(lr.my_id, lr.my_name, self.authorized); } } } else if let Some(message::Union::Auth2fa(tfa)) = msg.union { @@ -6139,13 +5991,10 @@ pub fn claim_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool { #[cfg(not(any(target_os = "android", target_os = "ios")))] // IPC bootstrap summary: -// - Resolve target CM socket (headless/non-headless, optional UID-scoped path on Linux). // - Start CM when missing, then bridge bidirectional messages between this task and CM IPC. async fn start_ipc( mut rx_to_cm: mpsc::UnboundedReceiver, tx_from_cm: mpsc::UnboundedSender, - mut _rx_desktop_ready: mpsc::Receiver<()>, - tx_stream_ready: mpsc::Sender<()>, ) -> ResultType<()> { use hbb_common::anyhow::anyhow; @@ -6155,139 +6004,51 @@ async fn start_ipc( } sleep(1.).await; } - #[cfg(target_os = "linux")] - let headless_cm = crate::is_server() - && crate::platform::is_headless_allowed() - && linux_desktop_manager::is_headless(); - #[cfg(not(target_os = "linux"))] - let headless_cm = false; let mut stream = None; - if !headless_cm { - if let Ok(s) = crate::ipc::connect(1000, "_cm").await { - stream = Some(s); - } + if let Ok(s) = crate::ipc::connect(1000, "_cm").await { + stream = Some(s); } if stream.is_none() { - #[allow(unused_mut)] - #[allow(unused_assignments)] - let mut args = vec!["--cm"]; - #[allow(unused_mut)] - #[cfg(target_os = "linux")] - let mut user = None; - - // Cm run as user, wait until desktop session is ready. - #[cfg(target_os = "linux")] - if headless_cm { - let mut username = linux_desktop_manager::get_cached_username(); - loop { - if !username.is_empty() { - break; + let args = vec!["--cm"]; + let run_done; + if crate::platform::is_root() { + let mut res = Ok(None); + for _ in 0..10 { + #[cfg(not(any(target_os = "linux")))] + { + log::debug!("Start cm"); + res = crate::platform::run_as_user(args.clone()); } - // `_rx_desktop_ready` is used as a wake-up signal from desktop/session state changes - // (for example wait_desktop_cm_ready paths). It is not itself a proof of CM readiness. - let wait_result = timeout(1_000, _rx_desktop_ready.recv()).await; - if matches!(wait_result, Ok(None)) { - return Err(anyhow!( - "Desktop-ready channel closed before a Linux session became available" - )); - } - username = linux_desktop_manager::get_cached_username(); - } - let uid = { - let username_for_cmd = username.clone(); - let mut uid_cmd = hbb_common::tokio::process::Command::new("id"); - // TODO: - // Keep current behavior for now to minimize change risk. - // If usernames starting with '-' are observed in the field, prefer: - // `id -u -- ` to avoid option-parsing ambiguity. - // Already verified that `id -u -- ` works as expected on macOS and Ubuntu 24.04. - uid_cmd.arg("-u").arg(&username_for_cmd).kill_on_drop(true); - let output = timeout(10_000, uid_cmd.output()) - .await - .map_err(|_| anyhow!("Timed out querying uid for {}", username))? - .map_err(|e| anyhow!("Failed to run `id -u {}`: {}", username, e))?; - if !output.status.success() { - bail!("Failed to query uid for {}", username); - } - let output = String::from_utf8_lossy(&output.stdout); - let output = output.trim(); - if output.parse::().is_err() { - bail!("Invalid uid {}", output); - } - output.to_string() - }; - user = Some((uid, username)); - args = vec!["--cm-no-ui"]; - } - #[cfg(target_os = "linux")] - let cm_uid: Option = match &user { - Some((uid, _)) => Some( - uid.parse::() - .map_err(|_| anyhow!("Invalid uid {}", uid))?, - ), - None => None, - }; - #[cfg(target_os = "linux")] - if let Some(uid) = cm_uid { - if let Ok(s) = crate::ipc::connect_for_uid(1000, uid, "_cm").await { - stream = Some(s); - } - } - if stream.is_none() { - let run_done; - if crate::platform::is_root() { - let mut res = Ok(None); - for _ in 0..10 { - #[cfg(not(any(target_os = "linux")))] - { - log::debug!("Start cm"); - res = crate::platform::run_as_user(args.clone()); - } - #[cfg(target_os = "linux")] - { - log::debug!("Start cm"); - res = crate::platform::run_as_user( - args.clone(), - user.clone(), - None::<(&str, &str)>, - ); - } - if res.is_ok() { - break; - } - log::error!("Failed to run cm: {res:?}"); - sleep(1.).await; - } - if let Some(task) = res? { - super::CHILD_PROCESS.lock().unwrap().push(task); - } - run_done = true; - } else { - run_done = false; - } - if !run_done { - log::debug!("Start cm"); - super::CHILD_PROCESS - .lock() - .unwrap() - .push(crate::run_me(args)?); - } - for _ in 0..20 { - sleep(0.3).await; #[cfg(target_os = "linux")] { - if let Some(uid) = cm_uid { - if let Ok(s) = crate::ipc::connect_for_uid(1000, uid, "_cm").await { - stream = Some(s); - break; - } - continue; - } + log::debug!("Start cm"); + res = crate::platform::run_as_user(args.clone(), None, None::<(&str, &str)>); } - if let Ok(s) = crate::ipc::connect(1000, "_cm").await { - stream = Some(s); + if res.is_ok() { break; } + log::error!("Failed to run cm: {res:?}"); + sleep(1.).await; + } + if let Some(task) = res? { + super::CHILD_PROCESS.lock().unwrap().push(task); + } + run_done = true; + } else { + run_done = false; + } + if !run_done { + log::debug!("Start cm"); + super::CHILD_PROCESS + .lock() + .unwrap() + .push(crate::run_me(args)?); + } + for _ in 0..20 { + sleep(0.3).await; + if let Ok(s) = crate::ipc::connect(1000, "_cm").await { + stream = Some(s); + break; } } } @@ -6295,7 +6056,6 @@ async fn start_ipc( bail!("Failed to connect to connection manager"); } - let _res = tx_stream_ready.send(()).await; let mut stream = stream.ok_or(anyhow!("none stream"))?; loop { tokio::select! { @@ -6609,84 +6369,6 @@ impl Drop for Connection { } } -// Login requests are unauthenticated here, so only one may reach loginctl/PAM at a time. -#[cfg(target_os = "linux")] -static LINUX_DESKTOP_START_IN_FLIGHT: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); - -#[cfg(target_os = "linux")] -struct LinuxDesktopStartGuard; - -#[cfg(target_os = "linux")] -impl Drop for LinuxDesktopStartGuard { - fn drop(&mut self) { - LINUX_DESKTOP_START_IN_FLIGHT.store(false, Ordering::Release); - } -} - -#[cfg(target_os = "linux")] -enum LinuxDesktopStartOutcome { - Finished(String), - Busy, -} - -#[cfg(target_os = "linux")] -struct LinuxHeadlessHandle { - pub is_headless_allowed: bool, - pub wait_ipc_timeout: u64, - pub rx_cm_stream_ready: mpsc::Receiver<()>, - pub tx_desktop_ready: mpsc::Sender<()>, -} - -#[cfg(target_os = "linux")] -impl LinuxHeadlessHandle { - pub fn new(rx_cm_stream_ready: mpsc::Receiver<()>, tx_desktop_ready: mpsc::Sender<()>) -> Self { - let is_headless_allowed = crate::is_server() && crate::platform::is_headless_allowed(); - Self { - is_headless_allowed, - wait_ipc_timeout: 10_000, - rx_cm_stream_ready, - tx_desktop_ready, - } - } - - pub async fn try_start_desktop( - &mut self, - os_login: Option<&OSLogin>, - ) -> LinuxDesktopStartOutcome { - let Some((username, password)) = - linux_desktop_start_credentials(self.is_headless_allowed, os_login) - else { - return LinuxDesktopStartOutcome::Finished(String::new()); - }; - if LINUX_DESKTOP_START_IN_FLIGHT.swap(true, Ordering::AcqRel) { - return LinuxDesktopStartOutcome::Busy; - } - let guard = LinuxDesktopStartGuard; - let err_msg = match tokio::task::spawn_blocking(move || { - let _guard = guard; - linux_desktop_manager::try_start_desktop(&username, &password) - }) - .await - { - Ok(err_msg) => err_msg, - Err(err) => { - log::error!("Linux desktop start task failed: {err}"); - crate::client::LOGIN_MSG_DESKTOP_XSESSION_FAILED.to_owned() - } - }; - LinuxDesktopStartOutcome::Finished(err_msg) - } - - pub async fn wait_desktop_cm_ready(&mut self) { - // A value captured at construction can lag behind a seat0 transition. - if self.is_headless_allowed && linux_desktop_manager::is_headless() { - self.tx_desktop_ready.send(()).await.ok(); - let _res = timeout(self.wait_ipc_timeout, self.rx_cm_stream_ready.recv()).await; - } - } -} - extern "C" fn connection_shutdown_hook() { // https://stackoverflow.com/questions/35980148/why-does-an-atexit-handler-panic-when-it-accesses-stdout // Please make sure there is no print in the call stack diff --git a/src/server/drm_capturer.rs b/src/server/drm_capturer.rs index 0c6beb493..fb7719b80 100644 --- a/src/server/drm_capturer.rs +++ b/src/server/drm_capturer.rs @@ -821,15 +821,14 @@ impl Drop for UinputRefreshGuard { } } -/// Never probes, never blocks: the form the ROUTING gates must use. Seconds of IPC inside -/// `wayland::clear()`, `is_inited()` or the display enumeration trips "deadline has elapsed". +/// Never probes or blocks. Use in hot paths such as `wayland::clear()`, `is_inited()`, and display +/// enumeration, where seconds of IPC would trip "deadline has elapsed". pub(crate) fn is_available_cached() -> bool { matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)) } -/// The three honest answers the availability machinery can give. `Unsettled` — another probe in -/// flight, or a failure still below the disable threshold — is not a verdict, and the -/// login-screen headless decision must not read it as one. +/// A tri-state assessment of DRM capture availability. +/// `Unsettled` means a probe is in flight or failures have not reached the disable threshold. #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum Availability { Available, @@ -843,9 +842,8 @@ pub(crate) enum Availability { fn availability() -> Availability { let (verdict, stale_no) = { let st = DRM_STATE.lock().unwrap(); - // A settled "no" STAYS the answer while an off-thread re-probe re-verifies it; going - // Unknown at expiry would reopen an Unsettled window every TTL on a helper-less box, and - // the login decision reads Unsettled as a possible greeter. + // Keep a settled "no" while an off-thread probe re-verifies it, avoiding a transient + // Unsettled result whenever the negative cache expires. let stale_no = matches!(&*st, ProbeState::Unavailable(since) if since.elapsed() >= NEGATIVE_TTL); let verdict = match &*st { @@ -878,10 +876,8 @@ fn availability() -> Availability { probe_and_publish() } -/// The non-blocking tri-state, for decisions on the LOGIN REQUEST path that must never wait: an -/// unauthenticated peer reaches that path, so a probe there would let it park a worker for the -/// probe deadline. Unknown kicks the probe off-thread and answers Unsettled, which the login -/// decision treats as a possibly servable greeter (no Xorg) until the state settles. +/// Non-blocking login-path assessment. +/// Unknown starts a probe off-thread; callers require `Available` before admitting a session. pub(crate) fn availability_cached() -> Availability { let (verdict, stale_no) = { let st = DRM_STATE.lock().unwrap(); diff --git a/src/ui/common.tis b/src/ui/common.tis index a1a0b8fac..8b90b4a43 100644 --- a/src/ui/common.tis +++ b/src/ui/common.tis @@ -272,30 +272,6 @@ function msgbox(type, title, content, link="", callback=null, height=180, width= handler.send2fa(res.code, res.trust_this_device || false); msgbox("connecting", "Connecting...", "Logging in..."); }; - } else if (type == "session-login" || type == "session-re-login") { - callback = function (res) { - if (!res) { - view.close(); - return; - } - handler.login(res.osusername, res.ospassword, "", false); - if (!is_port_forward) { - if (is_file_transfer) handler.msgbox("connecting", "Connecting...", "Logging in..."); - else msgbox("connecting", "Connecting...", "Logging in..."); - } - }; - } else if (type.indexOf("session-login") >= 0) { - callback = function (res) { - if (!res) { - view.close(); - return; - } - handler.login(res.osusername, res.ospassword, res.password, res.remember); - if (!is_port_forward) { - if (is_file_transfer) handler.msgbox("connecting", "Connecting...", "Logging in..."); - else msgbox("connecting", "Connecting...", "Logging in..."); - } - }; } else if (type.indexOf("insecure-connection") >= 0) { callback = function (res) { if (!res) { diff --git a/src/ui/msgbox.tis b/src/ui/msgbox.tis index 58547ce58..14b0bae02 100644 --- a/src/ui/msgbox.tis +++ b/src/ui/msgbox.tis @@ -41,7 +41,7 @@ class MsgboxComponent: Reactor.Component { } function getIcon(color) { - if (this.type == "input-password" || this.type == "session-login" || this.type == "session-login-password" || this.type == "input-2fa") { + if (this.type == "input-password" || this.type == "input-2fa") { return ; } if (this.type == "connecting") { @@ -50,7 +50,7 @@ class MsgboxComponent: Reactor.Component { if (this.type == "success") { return ; } - if (this.type.indexOf("error") >= 0 || this.type == "re-input-password" || this.type == "input-2fa" || this.type == "session-re-login" || this.type == "session-login-re-password") { + if (this.type.indexOf("error") >= 0 || this.type == "re-input-password" || this.type == "input-2fa") { return ; } return null; @@ -74,37 +74,11 @@ class MsgboxComponent: Reactor.Component { ; } - function getInputUserPasswordContent() { - return
-
{translate("OS Username")}
-
-
{translate("OS Password")}
- -
-
; - } - - function getXsessionPasswordContent() { - return
-
{translate("OS Username")}
-
-
{translate("OS Password")}
- -
{translate('Please enter your password')}
- -
{translate('Remember password')}
-
; - } - function getContent() { if (this.type == "input-password") { return this.getInputPasswordContent(); } else if (this.type == "input-2fa") { return this.get2faContent(); - } else if (this.type == "session-login") { - return this.getInputUserPasswordContent(); - } else if (this.type == "session-login-password") { - return this.getXsessionPasswordContent(); } else if (this.type == "custom-os-password") { var ts = this.autoLogin ? { checked: true } : {}; return
@@ -116,13 +90,13 @@ class MsgboxComponent: Reactor.Component { } function getColor() { - if (this.type == "input-password" || this.type == "input-2fa" || this.type == "custom-os-password" || this.type == "session-login" || this.type == "session-login-password") { + if (this.type == "input-password" || this.type == "input-2fa" || this.type == "custom-os-password") { return "#AD448E"; } if (this.type == "success") { return "#32bea6"; } - if (this.type.indexOf("error") >= 0 || this.type == "re-input-password" || this.type == "session-re-login" || this.type == "session-login-re-password") { + if (this.type.indexOf("error") >= 0 || this.type == "re-input-password") { return "#e04f5f"; } return "#2C8CFF"; @@ -242,16 +216,6 @@ class MsgboxComponent: Reactor.Component { this.update(); return; } - if (this.type == "session-re-login") { - this.type = "session-login"; - this.update(); - return; - } - if (this.type == "session-login-re-password") { - this.type = "session-login-password"; - this.update(); - return; - } var values = this.getValues(); if (this.callback) { var self = this; @@ -352,21 +316,6 @@ class MsgboxComponent: Reactor.Component { return; } } - if (this.type == "session-login") { - values.osusername = (values.osusername || "").trim(); - values.ospassword = (values.ospassword || "").trim(); - if (!values.osusername || !values.ospassword) { - return; - } - } - if (this.type == "session-login-password") { - values.password = (values.password || "").trim(); - values.osusername = (values.osusername || "").trim(); - values.ospassword = (values.ospassword || "").trim(); - if (!values.osusername || !values.ospassword || !values.password) { - return; - } - } if (this.type == "multiple-sessions-nocancel") { values.sid = (this.$$(select))[0].value; } From 19846787855aaf5031d34e54e0495f20b26083aa Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:48:30 +0800 Subject: [PATCH 36/72] Hide printer tab when settings disabled (#15901) * fix: hide the printer settings tab when settings are disabled The Security and Network tabs already honour `disable-settings`, but the Printer tab was gated only on `hide-remote-printer-settings`, so custom clients built with settings disabled still exposed it. https://github.com/rustdesk/rustdesk-server-pro/issues/1001 Co-Authored-By: Claude Opus 5 (1M context) * feat: add hide-general-settings builtin option Hides the General tab of the settings page. Unlike the other hide-*-settings options this one is still useful when settings are disabled, since `disable-settings` does not cover the General tab. https://github.com/rustdesk/rustdesk-server-pro/issues/1001 Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- flutter/lib/consts.dart | 1 + flutter/lib/desktop/pages/desktop_setting_page.dart | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 9eb21665a..092873793 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -191,6 +191,7 @@ const String kOptionHideProxySetting = "hide-proxy-settings"; const String kOptionHideWebSocketSetting = "hide-websocket-settings"; const String kOptionHideStopService = "hide-stop-service"; const String kOptionHideRemotePrinterSetting = "hide-remote-printer-settings"; +const String kOptionHideGeneralSetting = "hide-general-settings"; const String kOptionHideSecuritySetting = "hide-security-settings"; const String kOptionHideNetworkSetting = "hide-network-settings"; const String kOptionRemovePresetPasswordWarning = diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index c696ad510..4f3ea42e2 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -61,7 +61,8 @@ enum SettingsTabKey { class DesktopSettingPage extends StatefulWidget { final SettingsTabKey initialTabkey; static final List tabKeys = [ - SettingsTabKey.general, + if (bind.mainGetBuildinOption(key: kOptionHideGeneralSetting) != 'Y') + SettingsTabKey.general, if (!isWeb && !bind.isOutgoingOnly() && !bind.isDisableSettings() && @@ -73,6 +74,7 @@ class DesktopSettingPage extends StatefulWidget { if (!bind.isIncomingOnly()) SettingsTabKey.display, if (!bind.isDisableAccount()) SettingsTabKey.account, if (isWindows && + !bind.isDisableSettings() && bind.mainGetBuildinOption(key: kOptionHideRemotePrinterSetting) != 'Y') SettingsTabKey.printer, SettingsTabKey.about, From 630b531108f48363dac096de41729a454978ab66 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Wed, 19 Aug 2026 01:49:28 -0300 Subject: [PATCH 37/72] fix(flutter): initialize the cursor hotspot y from its own origin (#15898) The CursorData constructor copies hotxOrigin into hoty. Latent today: both consumers call updateGetKey() before reading, and _checkUpdateScale recomputes hoty from hotyOrigin - but any future read before that call inherits the x value silently. --- flutter/lib/models/model.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 74f72021b..129f67dea 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -2842,7 +2842,7 @@ class CursorData { required this.width, required this.height, }) : hotx = hotxOrigin * scale, - hoty = hotxOrigin * scale; + hoty = hotyOrigin * scale; int _doubleToInt(double v) => (v * 10e6).round().toInt(); From 56796705060a9f3297b542988928e4f15f125271 Mon Sep 17 00:00:00 2001 From: 21pages Date: Wed, 19 Aug 2026 15:08:58 +0800 Subject: [PATCH 38/72] fix(flutter): make Adjust Window reliable across desktop platforms (#15853) * fix(flutter): make Adjust Window reliable across desktop platforms - Fix incorrect sizing on scaled displays by calculating the target from the rendered canvas scale and platform-specific window coordinate units. - Fix adjustments using the wrong monitor by querying the current remote window's screen, with the main window as fallback. - Fix stale geometry after fullscreen or maximized transitions by refreshing metrics before calculating and applying the target frame. - Fix fullscreen availability checks on Windows and macOS by predicting the restored window borders and caching each macOS window's pre-fullscreen work area. - Fix incorrect Linux work areas by handling GNOME Wayland fractional scaling and caching compositor/X11 work-area measurements when visibleFrame is wrong. - Prevent unsafe adjustments by rejecting invalid, oversized, or implausibly small target frames. - Avoid failures during window teardown by skipping adjustment when the view, screen, or native window frame is unavailable. Signed-off-by: 21pages * fix(flutter): harden Adjust Window handling - Use the dynamic Linux resize edge when predicting restored window bounds. - Treat GNOME fractional-scaling lookup failures as unknown without repeating the lookup for the remote window. - Stop adjustment safely when native window calls fail during window teardown. Signed-off-by: 21pages * fix(flutter): correct Linux monitor selection Update window_size to use monitor height for vertical bounds, preventing incorrect screen selection with vertically stacked displays. Signed-off-by: 21pages * docs(flutter): simplify Linux screen handling comments Keep the source rationale concise and move platform measurements and investigation details out of the implementation. Signed-off-by: 21pages * fix(flutter): align Adjust Window resize padding Use the shared drag-to-resize padding for Linux restored-window predictions so menu validation matches the applied frame dimensions. Signed-off-by: 21pages * fix(flutter): remove Adjust Window screen fallback Return null when the current window screen is unavailable instead of using the main window's scale factor and work area. Signed-off-by: 21pages * fix(linux): query Mutter monitor layout mode Use DisplayConfig.GetCurrentState instead of inferring scaling from experimental features, and handle Ubuntu's UI-scaled logical mode. Signed-off-by: 21pages * fix(flutter): use native maximized state for Wayland cache Signed-off-by: 21pages * fix(flutter): allow Adjust Window to fill work area Signed-off-by: 21pages * fix(flutter): avoid racing screen info updates Signed-off-by: 21pages * refactor(flutter): remove dead Adjust Window web plumbing Signed-off-by: 21pages * fix(flutter): tolerate near-unity Wayland scale factors Signed-off-by: 21pages * fix(flutter): harden window screen detection Signed-off-by: 21pages * fix(linux): drop deprecated GNOME session detection Signed-off-by: 21pages * fix(flutter): remove GNOME monitor layout mode flutter cache Signed-off-by: 21pages --------- Signed-off-by: 21pages --- flutter/lib/common.dart | 2 - flutter/lib/consts.dart | 4 +- .../lib/desktop/pages/desktop_home_page.dart | 7 - .../lib/desktop/widgets/remote_toolbar.dart | 378 ++++++++++++++---- flutter/lib/native/common.dart | 2 - flutter/lib/web/common.dart | 3 - flutter/macos/Runner/MainFlutterWindow.swift | 30 ++ flutter/pubspec.lock | 6 +- flutter/pubspec.yaml | 4 +- src/flutter_ffi.rs | 8 + src/platform/linux.rs | 138 +++++++ 11 files changed, 483 insertions(+), 99 deletions(-) diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index 94c3c2a72..93c7a4d4b 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -84,8 +84,6 @@ const double _kPositionEpsilon = 1e-6; bool get isMainDesktopWindow => desktopType == DesktopType.main || desktopType == DesktopType.cm; -String get screenInfo => screenInfo_; - /// Check if the app is running with single view mode. bool isSingleViewApp() { return desktopType == DesktopType.cm; diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 092873793..10459e782 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -54,7 +54,6 @@ const String kAppTypeDesktopTerminal = "terminal"; const String kWindowMainWindowOnTop = "main_window_on_top"; const String kWindowRefreshCurrentUser = "refresh_current_user"; -const String kWindowGetWindowInfo = "get_window_info"; const String kWindowGetScreenList = "get_screen_list"; // This method is not used, maybe it can be removed. const String kWindowDisableGrabKeyboard = "disable_grab_keyboard"; @@ -324,10 +323,11 @@ double kNewWindowOffset = isWindows ? 30.0 : 50.0; +const kDragToResizeAreaPaddingSize = 5.0; EdgeInsets get kDragToResizeAreaPadding => !kUseCompatibleUiMode && isLinux ? stateGlobal.fullscreen.isTrue || stateGlobal.isMaximized.value ? EdgeInsets.zero - : EdgeInsets.all(5.0) + : EdgeInsets.all(kDragToResizeAreaPaddingSize) : EdgeInsets.zero; // https://en.wikipedia.org/wiki/Non-breaking_space const int $nbsp = 0x00A0; diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index 76d464198..6d370cbb0 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -780,13 +780,6 @@ class _DesktopHomePageState extends State windowOnTop(null); } else if (call.method == kWindowRefreshCurrentUser) { gFFI.userModel.refreshCurrentUser(); - } else if (call.method == kWindowGetWindowInfo) { - final screen = (await window_size.getWindowInfo()).screen; - if (screen == null) { - return ''; - } else { - return jsonEncode(screenToMap(screen)); - } } else if (call.method == kWindowGetScreenList) { return jsonEncode( (await window_size.getScreenList()).map(screenToMap).toList()); diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 2627627a6..19b3fa985 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -9,7 +9,6 @@ import 'package:flutter_hbb/common/widgets/toolbar.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/consts.dart'; -import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; @@ -1334,6 +1333,12 @@ class ScreenAdjustor { final FFI ffi; final VoidCallback cbExitFullscreen; window_size.Screen? _screen; + Size? _waylandMaximizedWorkAreaSize; + Rect? _waylandWorkAreaScreenFrame; + double? _waylandWorkAreaScaleFactor; + Rect? _x11WorkArea; + Rect? _x11WorkAreaScreenFrame; + double? _x11WorkAreaScaleFactor; ScreenAdjustor({ required this.id, @@ -1344,9 +1349,18 @@ class ScreenAdjustor { bool get isFullscreen => stateGlobal.fullscreen.isTrue; int get windowId => stateGlobal.windowId; + Future isWindowMaximized() async { + try { + return await WindowController.fromWindowId(windowId).isMaximized(); + } catch (_) { + // The delayed resolution callback may run after the window is disposed. + return null; + } + } + adjustWindow(BuildContext context) { return futureBuilder( - future: isWindowCanBeAdjusted(), + future: isWindowCanBeAdjusted(context), hasData: (data) { final visible = data as bool; if (!visible) return Offstage(); @@ -1362,36 +1376,201 @@ class ScreenAdjustor { }); } - doAdjustWindow(BuildContext context) async { - await updateScreen(); - if (_screen != null) { - cbExitFullscreen(); - double scale = _screen!.scaleFactor; - final wndRect = await WindowController.fromWindowId(windowId).getFrame(); - final mediaSize = MediaQueryData.fromView(View.of(context)).size; - // On windows, wndRect is equal to GetWindowRect and mediaSize is equal to GetClientRect. + // Linux screen and work-area coordinates can use different units or become + // unreliable across Wayland/X11 state changes, so normalize reported frames + // and cache usable work-area measurements before sizing the window. + + Future _updateLinuxWorkAreaCache({ + required window_size.Screen screen, + required Rect wndRect, + required bool isWayland, + required bool isX11, + required bool forMenu, + }) async { + if (isWayland && + (_waylandWorkAreaScreenFrame != screen.frame || + _waylandWorkAreaScaleFactor != screen.scaleFactor)) { + _waylandMaximizedWorkAreaSize = null; + _waylandWorkAreaScreenFrame = screen.frame; + _waylandWorkAreaScaleFactor = screen.scaleFactor; + } + if (isWayland && + forMenu && + !isFullscreen && + await isWindowMaximized() == true) { + _waylandMaximizedWorkAreaSize = wndRect.size; + } + if (isX11 && + (_x11WorkAreaScreenFrame != screen.frame || + _x11WorkAreaScaleFactor != screen.scaleFactor)) { + _x11WorkArea = null; + _x11WorkAreaScreenFrame = screen.frame; + _x11WorkAreaScaleFactor = screen.scaleFactor; + } + if (isX11 && forMenu && !isFullscreen) { + _x11WorkArea = screen.visibleFrame; + } + } + + Future _getEffectiveScreenFrame({ + required window_size.Screen screen, + required bool isWayland, + required bool isX11, + required bool forMenu, + }) async { + Rect frameRect = screen.visibleFrame; + if (isMacOS && forMenu && isFullscreen) { + List? workArea; + try { + workArea = await kMacOSPermChannel + .invokeListMethod('getMacOSWorkAreaSize'); + } catch (_) { + return null; + } + if (workArea == null || workArea.length != 2) { + return null; + } + frameRect = Rect.fromLTWH( + frameRect.left, + frameRect.top, + workArea[0] < frameRect.width ? workArea[0] : frameRect.width, + workArea[1] < frameRect.height ? workArea[1] : frameRect.height, + ); + } + final x11WorkArea = _x11WorkArea; + if (isX11 && + forMenu && + isFullscreen && + x11WorkArea != null && + (x11WorkArea.width < frameRect.width || + x11WorkArea.height < frameRect.height)) { + frameRect = x11WorkArea; + } + final screenScale = screen.scaleFactor; + if (isWayland && screenScale > 1.01) { + String monitorLayoutMode; + try { + monitorLayoutMode = + await bind.mainGetCommon(key: 'gnome-monitor-layout-mode'); + } catch (_) { + monitorLayoutMode = ''; + } + if (monitorLayoutMode == 'physical') { + frameRect = Rect.fromLTRB( + frameRect.left / screenScale, + frameRect.top / screenScale, + frameRect.right / screenScale, + frameRect.bottom / screenScale, + ); + } + } + return frameRect; + } + + Future _getAdjustedWindowFrame(Size mediaSize, + {bool forMenu = false}) async { + final screen = _screen; + if (screen != null) { + // Windows window frames use physical pixels while Flutter view sizes are + // logical. macOS and Linux window frames use the same units as Flutter. + double scale = isWindows ? screen.scaleFactor : 1.0; + final Rect wndRect; + try { + wndRect = await WindowController.fromWindowId(windowId).getFrame(); + } catch (e) { + debugPrint("Failed to get frame of window $windowId, it may be hidden"); + return null; + } + // On Windows, wndRect is GetWindowRect while mediaSize is GetClientRect. // https://stackoverflow.com/a/7561083 double magicWidth = wndRect.right - wndRect.left - mediaSize.width * scale; double magicHeight = wndRect.bottom - wndRect.top - mediaSize.height * scale; final canvasModel = ffi.canvasModel; + // canvasModel.scale is the rendered scale and already applies kIgnoreDpi. + // Use it instead of the remote source resolution. + final isWayland = isLinux && bind.mainCurrentIsWayland(); + final isX11 = isLinux && !isWayland; + await _updateLinuxWorkAreaCache( + screen: screen, + wndRect: wndRect, + isWayland: isWayland, + isX11: isX11, + forMenu: forMenu, + ); + if (isWindows && forMenu && isFullscreen) { + // desktop_multi_window's hidden title bar keeps 8 physical pixels on + // each horizontal edge and at the bottom, plus up to 1px at the top. + // Fullscreen removes these in WM_NCCALCSIZE, so predict the restored + // frame's worst-case padding when deciding whether to show the menu. + magicWidth = 16.0; + magicHeight = 9.0; + } + double horizontalEdges; + double verticalEdges; + if (forMenu && (isLinux || ((isMacOS || isWindows) && isFullscreen))) { + // Linux Adjust Window unmaximizes; macOS and Windows exit fullscreen + // before resizing. Predict the restored normal-window edges when + // deciding whether to show the menu item. + final resizePadding = isLinux && !kUseCompatibleUiMode + ? kDragToResizeAreaPaddingSize + : 0.0; + final windowEdge = kWindowBorderWidth + resizePadding; + horizontalEdges = windowEdge * 2; + verticalEdges = kDesktopRemoteTabBarHeight + windowEdge * 2; + } else { + horizontalEdges = CanvasModel.leftToEdge + CanvasModel.rightToEdge; + verticalEdges = CanvasModel.topToEdge + CanvasModel.bottomToEdge; + } final width = (canvasModel.getDisplayWidth() * canvasModel.scale + - CanvasModel.leftToEdge + - CanvasModel.rightToEdge) * + horizontalEdges) * scale + magicWidth; - final height = (canvasModel.getDisplayHeight() * canvasModel.scale + - CanvasModel.topToEdge + - CanvasModel.bottomToEdge) * - scale + - magicHeight; + final height = + (canvasModel.getDisplayHeight() * canvasModel.scale + verticalEdges) * + scale + + magicHeight; double left = wndRect.left + (wndRect.width - width) / 2; double top = wndRect.top + (wndRect.height - height) / 2; - Rect frameRect = _screen!.frame; - if (!isFullscreen) { - frameRect = _screen!.visibleFrame; + final frameRect = await _getEffectiveScreenFrame( + screen: screen, + isWayland: isWayland, + isX11: isX11, + forMenu: forMenu, + ); + if (frameRect == null) { + return null; + } + var availableSize = frameRect.size; + if (isWayland && forMenu && _waylandMaximizedWorkAreaSize != null) { + final cachedSize = _waylandMaximizedWorkAreaSize!; + availableSize = Size( + cachedSize.width < availableSize.width + ? cachedSize.width + : availableSize.width, + cachedSize.height < availableSize.height + ? cachedSize.height + : availableSize.height, + ); + } + // A window frame cannot be smaller than its client area. Tolerate small + // floating-point differences; larger negative values mean the native + // frame and Flutter view metrics are not synchronized. + if (magicWidth < -0.1 || magicHeight < -0.1) { + return null; + } + // Reject implausibly small targets to avoid hiding the window. + if (width < 300 || height < 300) { + return null; + } + // The remote size may change after the menu is built. Reject targets + // that exceed the available area. + final exceedsScreen = + width > availableSize.width || height > availableSize.height; + if (exceedsScreen) { + return null; } if (left < frameRect.left) { left = frameRect.left; @@ -1405,69 +1584,101 @@ class ScreenAdjustor { if ((top + height) > frameRect.bottom) { top = frameRect.bottom - height; } - await WindowController.fromWindowId(windowId) - .setFrame(Rect.fromLTWH(left, top, width, height)); + return Rect.fromLTWH(left, top, width, height); + } + return null; + } + + doAdjustWindow([BuildContext? context]) async { + // A resolution change is adjusted after a delay, when the menu context may + // already be disposed. Each desktop_multi_window window has its own engine, + // so that engine's first view is the current window. + final views = WidgetsBinding.instance.platformDispatcher.views; + if (context == null && views.isEmpty) { + return; + } + final view = context != null ? View.of(context) : views.first; + await updateScreen(); + if (_screen != null) { + final wc = WindowController.fromWindowId(windowId); + final wasFullscreen = isFullscreen; + cbExitFullscreen(); + if (wasFullscreen) { + // Wait for the native fullscreen exit to update the window frame. + await Future.delayed(Duration(milliseconds: 700)); + await updateScreen(); + } + if (isLinux) { + final isMaximized = await isWindowMaximized(); + if (isMaximized == null) { + return; + } + if (isMaximized == true) { + // setFrame may be ignored while the native window is maximized. + try { + await wc.unmaximize(); + } catch (_) { + return; + } + stateGlobal.setMaximized(false); + // Wait for the window manager and Flutter view metrics to reflect + // the restored window before calculating and setting its frame. + await Future.delayed(Duration(milliseconds: 300)); + await updateScreen(); + } + } + final mediaSize = MediaQueryData.fromView(view).size; + final frame = await _getAdjustedWindowFrame(mediaSize); + if (frame == null) { + return; + } + try { + await wc.setFrame(frame); + } catch (_) { + return; + } stateGlobal.setMaximized(false); } } updateScreen() async { - final String info = - isWeb ? screenInfo : await _getScreenInfoDesktop() ?? ''; - if (info.isEmpty) { - _screen = null; - } else { - final screenMap = jsonDecode(info); - _screen = window_size.Screen( - Rect.fromLTRB(screenMap['frame']['l'], screenMap['frame']['t'], - screenMap['frame']['r'], screenMap['frame']['b']), - Rect.fromLTRB( - screenMap['visibleFrame']['l'], - screenMap['visibleFrame']['t'], - screenMap['visibleFrame']['r'], - screenMap['visibleFrame']['b']), - screenMap['scaleFactor']); + _screen = await _getCurrentScreen(); + } + + Future _getCurrentScreen() async { + try { + return (await window_size.getWindowInfo()).screen; + } catch (e) { + debugPrint('Failed to get current window screen: $e'); + return null; } } - _getScreenInfoDesktop() async { - final v = await rustDeskWinManager.call( - WindowType.Main, kWindowGetWindowInfo, ''); - return v.result; - } - - Future isWindowCanBeAdjusted() async { + Future isWindowCanBeAdjusted([BuildContext? context]) async { + if (isWeb) { + return false; + } + // Capture the view before awaiting because the menu context may be disposed. + final views = WidgetsBinding.instance.platformDispatcher.views; + if (context == null && views.isEmpty) { + return false; + } + final view = context != null ? View.of(context) : views.first; + final mediaSize = MediaQueryData.fromView(view).size; final viewStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; if (viewStyle != kRemoteViewStyleOriginal) { return false; } - if (!isWeb) { - final remoteCount = RemoteCountState.find().value; - if (remoteCount != 1) { - return false; - } + final remoteCount = RemoteCountState.find().value; + if (remoteCount != 1) { + return false; } + await updateScreen(); if (_screen == null) { return false; } - final scale = kIgnoreDpi ? 1.0 : _screen!.scaleFactor; - double selfWidth = _screen!.visibleFrame.width; - double selfHeight = _screen!.visibleFrame.height; - if (isFullscreen) { - selfWidth = _screen!.frame.width; - selfHeight = _screen!.frame.height; - } - - final canvasModel = ffi.canvasModel; - final displayWidth = canvasModel.getDisplayWidth(); - final displayHeight = canvasModel.getDisplayHeight(); - final requiredWidth = - CanvasModel.leftToEdge + displayWidth + CanvasModel.rightToEdge; - final requiredHeight = - CanvasModel.topToEdge + displayHeight + CanvasModel.bottomToEdge; - return selfWidth > (requiredWidth * scale) && - selfHeight > (requiredHeight * scale); + return await _getAdjustedWindowFrame(mediaSize, forMenu: true) != null; } } @@ -1518,7 +1729,6 @@ class _DisplayMenuState extends State<_DisplayMenu> { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - _screenAdjustor.updateScreen(); menuChildrenGetter(_IconSubmenuButtonState state) { final menuChildren = [ _screenAdjustor.adjustWindow(context), @@ -2082,15 +2292,19 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { Future _getLocalResolutionWayland() async { if (!isWayland) return _getLocalResolution(); - final window = await window_size.getWindowInfo(); - final screen = window.screen; - if (screen != null) { - setState(() { - _localResolution = Resolution( - screen.frame.width.toInt(), - screen.frame.height.toInt(), - ); - }); + try { + final window = await window_size.getWindowInfo(); + final screen = window.screen; + if (screen != null) { + setState(() { + _localResolution = Resolution( + screen.frame.width.toInt(), + screen.frame.height.toInt(), + ); + }); + } + } catch (e) { + debugPrint('Failed to get local resolution on Wayland: $e'); } } @@ -2162,8 +2376,16 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { return; } if (w == rect.width.toInt() && h == rect.height.toInt()) { - if (await widget.screenAdjustor.isWindowCanBeAdjusted()) { - widget.screenAdjustor.doAdjustWindow(context); + if (!await widget.screenAdjustor.isWindowCanBeAdjusted()) { + return; + } + if (widget.screenAdjustor.isFullscreen) { + return; + } + if ((await widget.screenAdjustor.isWindowMaximized()) == false) { + // This delayed callback can outlive the menu State, so its context + // is unsafe. + widget.screenAdjustor.doAdjustWindow(); } } }); diff --git a/flutter/lib/native/common.dart b/flutter/lib/native/common.dart index 96d5bd6e8..1e76c70c5 100644 --- a/flutter/lib/native/common.dart +++ b/flutter/lib/native/common.dart @@ -10,8 +10,6 @@ final isWebDesktop_ = false; final isDesktop_ = Platform.isWindows || Platform.isMacOS || Platform.isLinux; -String get screenInfo_ => ''; - final isWebOnWindows_ = false; final isWebOnLinux_ = false; final isWebOnMacOS_ = false; diff --git a/flutter/lib/web/common.dart b/flutter/lib/web/common.dart index 4d539d5d4..a552752a8 100644 --- a/flutter/lib/web/common.dart +++ b/flutter/lib/web/common.dart @@ -1,5 +1,4 @@ import 'dart:js' as js; -import 'dart:html' as html; // cycle imports, maybe we can improve this import 'package:flutter_hbb/consts.dart'; @@ -13,8 +12,6 @@ final isWebDesktop_ = !js.context.callMethod('isMobile'); final isDesktop_ = false; -String get screenInfo_ => js.context.callMethod('getByName', ['screen_info']); - final _localOs = js.context.callMethod('getByName', ['local_os', '']); final isWebOnWindows_ = _localOs == kPeerPlatformWindows; final isWebOnLinux_ = _localOs == kPeerPlatformLinux; diff --git a/flutter/macos/Runner/MainFlutterWindow.swift b/flutter/macos/Runner/MainFlutterWindow.swift index 1cc72419b..336d94f1d 100644 --- a/flutter/macos/Runner/MainFlutterWindow.swift +++ b/flutter/macos/Runner/MainFlutterWindow.swift @@ -36,8 +36,28 @@ class RelativeMouseState { } class MainFlutterWindow: NSWindow { + private static let fullscreenWorkAreaSizes = NSMapTable( + keyOptions: [.weakMemory, .objectPointerPersonality], + valueOptions: .strongMemory + ) + private static let fullscreenObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willEnterFullScreenNotification, + object: nil, + queue: .main + ) { notification in + guard let window = notification.object as? NSWindow, + let screen = window.screen else { + return + } + fullscreenWorkAreaSizes.setObject( + NSValue(size: screen.visibleFrame.size), + forKey: window + ) + } + override func awakeFromNib() { rustdesk_core_main(); + _ = MainFlutterWindow.fullscreenObserver let flutterViewController = FlutterViewController.init() let windowFrame = self.frame self.contentViewController = flutterViewController @@ -278,6 +298,16 @@ class MainFlutterWindow: NSWindow { self.disableNativeRelativeMouseMode() result(true) + case "getMacOSWorkAreaSize": + guard Thread.isMainThread, + let window = registrar.view?.window, + let size = MainFlutterWindow.fullscreenWorkAreaSizes + .object(forKey: window)?.sizeValue else { + result(nil) + break + } + result([Double(size.width), Double(size.height)]) + default: result(FlutterMethodNotImplemented) } diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index cba9ba5ea..26fd3de72 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -1597,9 +1597,9 @@ packages: dependency: "direct main" description: path: "plugins/window_size" - ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601 - resolved-ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601 - url: "https://github.com/google/flutter-desktop-embedding.git" + ref: "51e67ce047c72b26810b99e8473ddb44612fe356" + resolved-ref: "51e67ce047c72b26810b99e8473ddb44612fe356" + url: "https://github.com/21pages/flutter-desktop-embedding.git" source: git version: "0.1.0" xdg_directories: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index b9f8e1ccb..64c5018f5 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -61,9 +61,9 @@ dependencies: flutter_custom_cursor: ^0.0.4 window_size: git: - url: https://github.com/google/flutter-desktop-embedding.git + url: https://github.com/21pages/flutter-desktop-embedding.git path: plugins/window_size - ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601 + ref: 51e67ce047c72b26810b99e8473ddb44612fe356 get: ^4.6.5 visibility_detector: ^0.4.0+2 contextmenu: ^3.0.0 diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index f840ed282..4064162ff 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -2655,6 +2655,14 @@ pub fn main_get_common(key: String) -> String { return crate::platform::linux::has_gnome_shortcuts_inhibitor_permission().to_string(); #[cfg(not(target_os = "linux"))] return false.to_string(); + } else if key == "gnome-monitor-layout-mode" { + #[cfg(target_os = "linux")] + return match crate::platform::linux::gnome_monitor_layout_mode() { + Some(mode) => mode.as_str().to_owned(), + None => String::new(), + }; + #[cfg(not(target_os = "linux"))] + return String::new(); } else if key == "permanent-password-set" { return ui_interface::is_permanent_password_set().to_string(); } else if key == "local-permanent-password-set" { diff --git a/src/platform/linux.rs b/src/platform/linux.rs index d187b0ec2..4fba6e669 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -96,6 +96,9 @@ lazy_static::lazy_static! { }; static ref ACTIVE_USER_LOOKUP_CACHE: std::sync::Mutex> = std::sync::Mutex::new(None); + static ref GNOME_MONITOR_LAYOUT_MODE_CACHE: std::sync::Mutex< + Option<(Instant, Option)>, + > = Default::default(); // https://github.com/rustdesk/rustdesk/issues/13705 // Check if `sudo -E` actually preserves environment. // @@ -128,6 +131,141 @@ lazy_static::lazy_static! { }; } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GnomeMonitorLayoutMode { + Logical, + Physical, +} + +impl GnomeMonitorLayoutMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Logical => "logical", + Self::Physical => "physical", + } + } +} + +fn gnome_monitor_layout_mode_from_value(value: u32) -> Option { + // Upstream: https://gitlab.gnome.org/GNOME/mutter/-/blob/main/data/dbus-interfaces/org.gnome.Mutter.DisplayConfig.xml + // Ubuntu mode 3: https://git.launchpad.net/ubuntu/+source/mutter/tree/debian/patches/x11-Add-support-for-fractional-scaling-using-Randr.patch + match value { + 1 | 3 => Some(GnomeMonitorLayoutMode::Logical), + 2 => Some(GnomeMonitorLayoutMode::Physical), + _ => None, + } +} + +pub fn gnome_monitor_layout_mode() -> Option { + if let Ok(cache) = GNOME_MONITOR_LAYOUT_MODE_CACHE.lock() { + if let Some((updated_at, result)) = *cache { + if updated_at.elapsed() < Duration::from_secs(10) { + return result; + } + } + } + + let result = (|| { + let is_gnome_desktop = std::env::var("XDG_CURRENT_DESKTOP") + .unwrap_or_default() + .split(':') + .any(|desktop| { + desktop.eq_ignore_ascii_case("gnome") || desktop.eq_ignore_ascii_case("unity") + }); + let is_gnome_session = std::env::var("DESKTOP_SESSION") + .unwrap_or_default() + .to_ascii_lowercase(); + if !is_gnome_desktop && !is_gnome_session.contains("gnome") { + return None; + } + use dbus::{arg::PropMap, blocking::BlockingSender}; + + let conn = match dbus::blocking::Connection::new_session() { + Ok(conn) => conn, + Err(err) => { + log::warn!("Failed to connect to the session bus for GNOME monitor layout: {err}"); + return None; + } + }; + let message = match dbus::Message::new_method_call( + "org.gnome.Mutter.DisplayConfig", + "/org/gnome/Mutter/DisplayConfig", + "org.gnome.Mutter.DisplayConfig", + "GetCurrentState", + ) { + Ok(message) => message, + Err(err) => { + log::warn!("Failed to create GNOME monitor layout query: {err}"); + return None; + } + }; + let reply = match conn.send_with_reply_and_block(message, Duration::from_secs(2)) { + Ok(reply) => reply, + Err(err) => { + log::warn!("Failed to query GNOME monitor layout: {err}"); + return None; + } + }; + let mut args = reply.iter_init(); + for _ in 0..3 { + if !args.next() { + log::warn!("GNOME monitor layout reply is missing properties"); + return None; + } + } + let properties: PropMap = match args.read() { + Ok(properties) => properties, + Err(err) => { + log::warn!("Failed to read GNOME monitor layout properties: {err}"); + return None; + } + }; + let Some(value) = dbus::arg::prop_cast::(&properties, "layout-mode").copied() else { + log::warn!("GNOME monitor layout reply has no layout-mode"); + return None; + }; + let mode = gnome_monitor_layout_mode_from_value(value); + if mode.is_none() { + log::warn!("GNOME monitor layout reply has unknown layout-mode {value}"); + } + mode + })(); + if let Ok(mut cache) = GNOME_MONITOR_LAYOUT_MODE_CACHE.lock() { + *cache = Some((Instant::now(), result)); + } + result +} + +#[cfg(test)] +mod gnome_monitor_layout_tests { + use super::*; + + #[test] + fn maps_logical_layouts() { + assert_eq!( + gnome_monitor_layout_mode_from_value(1), + Some(GnomeMonitorLayoutMode::Logical) + ); + assert_eq!( + gnome_monitor_layout_mode_from_value(3), + Some(GnomeMonitorLayoutMode::Logical) + ); + } + + #[test] + fn maps_physical_layout() { + assert_eq!( + gnome_monitor_layout_mode_from_value(2), + Some(GnomeMonitorLayoutMode::Physical) + ); + } + + #[test] + fn rejects_unknown_layout() { + assert_eq!(gnome_monitor_layout_mode_from_value(4), None); + } +} + #[inline] fn update_active_user_lookup_cache(desktop: &Desktop) { if let Ok(mut cache) = ACTIVE_USER_LOOKUP_CACHE.lock() { From 0a4b431ea224757cb46796bad3b065f3a97013eb Mon Sep 17 00:00:00 2001 From: fufesou Date: Thu, 20 Aug 2026 13:10:45 +0800 Subject: [PATCH 39/72] fix: correct terminal mouse selection and scroll coordinates (#15915) Signed-off-by: fufesou --- flutter/lib/desktop/pages/terminal_page.dart | 4 +- .../models/terminal_mouse_drag_reporter.dart | 235 ++++++++++++++ .../lib/models/terminal_mouse_handler.dart | 288 +++++++++++++++++- flutter/test/terminal_mouse_handler_test.dart | 155 ++++++++++ 4 files changed, 664 insertions(+), 18 deletions(-) create mode 100644 flutter/lib/models/terminal_mouse_drag_reporter.dart diff --git a/flutter/lib/desktop/pages/terminal_page.dart b/flutter/lib/desktop/pages/terminal_page.dart index e5e1dbb8d..f193bb23a 100644 --- a/flutter/lib/desktop/pages/terminal_page.dart +++ b/flutter/lib/desktop/pages/terminal_page.dart @@ -5,7 +5,7 @@ import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; import 'package:flutter_hbb/models/model.dart'; import 'package:flutter_hbb/models/terminal_model.dart'; -import 'package:xterm/xterm.dart'; +import 'package:flutter_hbb/models/terminal_mouse_handler.dart'; import 'terminal_connection_manager.dart'; class TerminalPage extends StatefulWidget { @@ -197,7 +197,7 @@ class _TerminalPageState extends State body: LayoutBuilder( builder: (context, constraints) { final heightPx = constraints.maxHeight; - return TerminalView( + return TerminalMouseInteraction( _terminalModel.terminal, controller: _terminalModel.terminalController, focusNode: _terminalFocusNode, diff --git a/flutter/lib/models/terminal_mouse_drag_reporter.dart b/flutter/lib/models/terminal_mouse_drag_reporter.dart new file mode 100644 index 000000000..d08fc9afd --- /dev/null +++ b/flutter/lib/models/terminal_mouse_drag_reporter.dart @@ -0,0 +1,235 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:xterm/xterm.dart'; + +const _cellIndexOffset = 1; +const _legacyCodeOffset = 32; +const _leftButtonCode = 0; +const _motionButtonCode = 32; +const _releaseButtonCode = 3; +const _shiftModifierCode = 4; +const _metaModifierCode = 8; +const _controlModifierCode = 16; +const _modifierCodeMask = + _shiftModifierCode | _metaModifierCode | _controlModifierCode; +const _normalCoordinateLimit = 223; +const _utfCoordinateLimit = 2015; + +String encodeTerminalMouseReport( + MouseReportMode mode, + int button, + CellOffset position, { + bool release = false, +}) { + final x = position.x + _cellIndexOffset; + final y = position.y + _cellIndexOffset; + final reportedButton = + release ? _releaseButtonCode | (button & _modifierCodeMask) : button; + switch (mode) { + case MouseReportMode.normal: + case MouseReportMode.utf: + final limit = mode == MouseReportMode.normal + ? _normalCoordinateLimit + : _utfCoordinateLimit; + final encodedButton = + String.fromCharCode(_legacyCodeOffset + reportedButton); + return '\x1b[M$encodedButton${_legacyCoordinate(x, limit)}' + '${_legacyCoordinate(y, limit)}'; + case MouseReportMode.sgr: + final suffix = release ? 'm' : 'M'; + return '\x1b[<$button;$x;$y$suffix'; + case MouseReportMode.urxvt: + return '\x1b[${_legacyCodeOffset + reportedButton};$x;${y}M'; + } +} + +String _legacyCoordinate(int value, int limit) => + value > limit ? '\x00' : String.fromCharCode(_legacyCodeOffset + value); + +int _activeModifierCode() { + final keyboard = HardwareKeyboard.instance; + return (keyboard.isShiftPressed ? _shiftModifierCode : 0) | + (keyboard.isAltPressed ? _metaModifierCode : 0) | + (keyboard.isControlPressed ? _controlModifierCode : 0); +} + +class TerminalMouseDragReporter { + int? _pointerId; + TerminalController? _controller; + late CellOffset _lastReportedPosition; + var _ownsControllerSuspension = false; + var _releasePending = false; + var _reporting = false; + + bool handleDown( + PointerDownEvent event, + Terminal terminal, + TerminalViewState? terminalView, + ) { + if (!_isPrimaryMouse(event) || !_reportsDrag(terminal.mouseMode)) { + return false; + } + if (terminalView == null || terminalView.widget.readOnly) return false; + final controller = terminalView.widget.controller; + if (controller == null || + controller.suspendedPointerInputs || + !controller.pointerInput.inputs.contains(PointerInput.tap)) { + return false; + } + + cancel(); + _pointerId = event.pointer; + _controller = controller; + _ownsControllerSuspension = true; + _releasePending = true; + _reporting = true; + controller.setSuspendPointerInput(true); + _clearSelection(controller); + final position = _cellAt(event, terminalView); + _lastReportedPosition = position; + terminal.textInput( + _report(terminal.mouseReportMode, position), + ); + return true; + } + + bool handleMove( + PointerMoveEvent event, + Terminal terminal, + TerminalViewState? terminalView, + ) { + if (event.pointer != _pointerId) return false; + if (terminalView == null) { + cancel(); + return true; + } + final reportsDrag = _reportsDrag(terminal.mouseMode); + if (!_isPrimaryMouse(event)) { + if (_releasePending && reportsDrag) { + _reportRelease( + terminal, + _reporting ? _cellAt(event, terminalView) : _lastReportedPosition, + ); + } + cancel(); + return true; + } + if (!_reporting || !reportsDrag) { + if (!reportsDrag) _releasePending = false; + _reporting = false; + // Keep ownership until the matching end event to suppress local selection. + final controller = _controller; + scheduleMicrotask(() => _clearSelection(controller)); + return true; + } + + final position = _cellAt(event, terminalView); + _lastReportedPosition = position; + terminal.textInput( + _report(terminal.mouseReportMode, position, motion: true), + ); + final controller = _controller; + scheduleMicrotask(() => _clearSelection(controller)); + return true; + } + + bool handleEnd( + PointerEvent event, + Terminal terminal, + TerminalViewState? terminalView, + ) { + if (event.pointer != _pointerId) return false; + if (terminalView != null && + _releasePending && + _reportsDrag(terminal.mouseMode)) { + _reportRelease( + terminal, + _reporting ? _cellAt(event, terminalView) : _lastReportedPosition, + ); + } + _clearSelection(_controller); + final controller = _controller; + _pointerId = null; + // Keep xterm's tap recognizer suspended for this pointer event. + scheduleMicrotask(() { + if (_pointerId == null && identical(_controller, controller)) { + _clearSelection(controller); + cancel(); + } + }); + return true; + } + + void cancel() { + final controller = _controller; + if (_ownsControllerSuspension) { + controller?.setSuspendPointerInput(false); + } + _pointerId = null; + _controller = null; + _ownsControllerSuspension = false; + _releasePending = false; + _reporting = false; + } + + void updateController(TerminalController controller) { + final oldController = _controller; + if (_pointerId == null || oldController == null) { + cancel(); + return; + } + if (identical(oldController, controller)) return; + if (_ownsControllerSuspension) { + oldController.setSuspendPointerInput(false); + } + final acceptsPointerInput = !controller.suspendedPointerInputs && + controller.pointerInput.inputs.contains(PointerInput.tap); + _controller = controller; + _ownsControllerSuspension = acceptsPointerInput; + _reporting = _reporting && acceptsPointerInput; + if (_ownsControllerSuspension) controller.setSuspendPointerInput(true); + _clearSelection(controller); + } + + void _reportRelease(Terminal terminal, CellOffset position) { + terminal.textInput( + _report( + terminal.mouseReportMode, + position, + release: true, + ), + ); + } + + CellOffset _cellAt(PointerEvent event, TerminalViewState terminalView) { + final renderTerminal = terminalView.renderTerminal; + return renderTerminal.getCellOffset( + renderTerminal.globalToLocal(event.position), + ); + } + + bool _isPrimaryMouse(PointerEvent event) => + event.kind == PointerDeviceKind.mouse && + (event.buttons & kPrimaryMouseButton) == kPrimaryMouseButton; + + bool _reportsDrag(MouseMode mode) => + mode == MouseMode.upDownScrollDrag || mode == MouseMode.upDownScrollMove; + + void _clearSelection(TerminalController? controller) { + if (controller == null || controller.selection == null) return; + controller.clearSelection(); + } + + String _report( + MouseReportMode mode, + CellOffset position, { + bool release = false, + bool motion = false, + }) { + final baseButton = motion ? _motionButtonCode : _leftButtonCode; + final button = baseButton | _activeModifierCode(); + return encodeTerminalMouseReport(mode, button, position, release: release); + } +} diff --git a/flutter/lib/models/terminal_mouse_handler.dart b/flutter/lib/models/terminal_mouse_handler.dart index a6a617488..6c5638793 100644 --- a/flutter/lib/models/terminal_mouse_handler.dart +++ b/flutter/lib/models/terminal_mouse_handler.dart @@ -1,10 +1,18 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; import 'package:xterm/xterm.dart'; +import 'terminal_mouse_drag_reporter.dart'; + /// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift /// modifier, so strict full-screen apps ignore the report and never scroll. /// Upstream fix: TerminalStudio/xterm.dart#238. class WheelButtonFixMouseHandler implements TerminalMouseHandler { - const WheelButtonFixMouseHandler(); + const WheelButtonFixMouseHandler({this.positionProvider}); + + final CellOffset? Function()? positionProvider; @override String? call(TerminalMouseEvent event) { @@ -23,20 +31,268 @@ class WheelButtonFixMouseHandler implements TerminalMouseHandler { String _reportWheel(TerminalMouseEvent event) { // Wheel buttons 4..7 go on the wire as 64..67, but `id` is 64 + 4..7. final button = event.button.id - 4; - final x = event.position.x + 1; - final y = event.position.y + 1; - switch (event.state.mouseReportMode) { - case MouseReportMode.normal: - case MouseReportMode.utf: - final limit = - event.state.mouseReportMode == MouseReportMode.normal ? 223 : 2015; - final col = x > limit ? '\x00' : String.fromCharCode(32 + x); - final row = y > limit ? '\x00' : String.fromCharCode(32 + y); - return '\x1b[M${String.fromCharCode(32 + button)}$col$row'; - case MouseReportMode.sgr: - return '\x1b[<$button;$x;${y}M'; - case MouseReportMode.urxvt: - return '\x1b[${32 + button};$x;${y}M'; - } + final position = positionProvider?.call() ?? event.position; + return encodeTerminalMouseReport( + event.state.mouseReportMode, + button, + position, + ); + } +} + +class TerminalMouseInteraction extends StatefulWidget { + const TerminalMouseInteraction( + this.terminal, { + super.key, + required this.controller, + this.focusNode, + this.backgroundOpacity = 1, + this.padding, + this.onSecondaryTapDown, + }); + + final Terminal terminal; + final TerminalController controller; + final FocusNode? focusNode; + final double backgroundOpacity; + final EdgeInsets? padding; + final void Function(TapDownDetails, CellOffset)? onSecondaryTapDown; + + @override + State createState() => + _TerminalMouseInteractionState(); +} + +class _TerminalMouseInteractionState extends State { + static const _selectionScrollInterval = Duration(milliseconds: 50); + static const _noScroll = 0; + static const _scrollUp = -1; + static const _scrollDown = 1; + + final _terminalViewKey = GlobalKey(); + final _scrollController = ScrollController(); + final _mouseDrag = TerminalMouseDragReporter(); + late final WheelButtonFixMouseHandler _mouseHandler; + TerminalMouseHandler? _previousMouseHandler; + Offset? _pointerPosition; + Offset? _selectionPointer; + CellAnchor? _selectionBase; + Buffer? _selectionBuffer; + int? _selectionPointerId; + Timer? _selectionScrollTimer; + var _selectionHasScrolled = false; + var _scrollDirection = _noScroll; + TerminalViewState? get _terminalView => _terminalViewKey.currentState; + + @override + void initState() { + super.initState(); + _mouseHandler = WheelButtonFixMouseHandler( + positionProvider: _cellAtPointer, + ); + _installMouseHandler(widget.terminal); + } + + @override + void didUpdateWidget(TerminalMouseInteraction oldWidget) { + super.didUpdateWidget(oldWidget); + final terminalChanged = !identical(oldWidget.terminal, widget.terminal); + final controllerChanged = + !identical(oldWidget.controller, widget.controller); + if (!terminalChanged && !controllerChanged) return; + if (controllerChanged && !terminalChanged) { + _mouseDrag.updateController(widget.controller); + } else { + _mouseDrag.cancel(); + } + _clearSelectionDrag(); + if (!terminalChanged) return; + _restoreMouseHandler(oldWidget.terminal); + _installMouseHandler(widget.terminal); + } + + void _installMouseHandler(Terminal terminal) { + _previousMouseHandler = terminal.mouseHandler; + terminal.mouseHandler = _mouseHandler; + } + + void _restoreMouseHandler(Terminal terminal) { + if (identical(terminal.mouseHandler, _mouseHandler)) { + terminal.mouseHandler = _previousMouseHandler; + } + } + + CellOffset? _cellAtPointer() { + final terminalView = _terminalView; + final pointerPosition = _pointerPosition; + if (terminalView == null || pointerPosition == null) return null; + final renderTerminal = terminalView.renderTerminal; + return renderTerminal.getCellOffset( + renderTerminal.globalToLocal(pointerPosition), + ); + } + + void _updatePointerPosition(PointerEvent event) => + _pointerPosition = event.position; + + void _handlePointerDown(PointerDownEvent event) { + _updatePointerPosition(event); + if (_mouseDrag.handleDown(event, widget.terminal, _terminalView)) { + _clearSelectionDrag(); + return; + } + if (event.kind != PointerDeviceKind.mouse || + (event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) { + return; + } + _clearSelectionDrag(); + final terminalView = _terminalView; + if (terminalView == null) return; + final renderTerminal = terminalView.renderTerminal; + final localPosition = renderTerminal.globalToLocal(event.position); + final selectionBuffer = widget.terminal.buffer; + _selectionPointerId = event.pointer; + _selectionBase = selectionBuffer.createAnchorFromOffset( + renderTerminal.getCellOffset(localPosition), + ); + _selectionBuffer = selectionBuffer; + _selectionPointer = localPosition; + } + + void _handlePointerMove(PointerMoveEvent event) { + _updatePointerPosition(event); + if (_mouseDrag.handleMove(event, widget.terminal, _terminalView)) return; + if (event.pointer != _selectionPointerId) return; + if (event.kind != PointerDeviceKind.mouse || + (event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) { + _clearSelectionDrag(); + return; + } + final terminalView = _terminalView; + if (terminalView == null || _selectionBase == null) return; + final renderTerminal = terminalView.renderTerminal; + final localPosition = renderTerminal.globalToLocal(event.position); + _selectionPointer = localPosition; + _setScrollDirection( + _directionFor(localPosition, renderTerminal.paintBounds), + ); + if (_selectionHasScrolled) { + scheduleMicrotask(() => _scrollSelection(scroll: false)); + } + } + + int _directionFor(Offset position, Rect bounds) { + if (position.dy < bounds.top) return _scrollUp; + if (position.dy >= bounds.bottom) return _scrollDown; + return _noScroll; + } + + void _setScrollDirection(int direction) { + if (_scrollDirection == direction) return; + _stopAutoScroll(); + _scrollDirection = direction; + if (direction == _noScroll) return; + _scrollSelection(); + if (_scrollDirection != _noScroll) { + _selectionScrollTimer = Timer.periodic( + _selectionScrollInterval, + (_) => _scrollSelection(), + ); + } + } + + void _scrollSelection({bool scroll = true}) { + final terminalView = _terminalView; + final selectionBase = _selectionBase; + final selectionBuffer = _selectionBuffer; + final selectionPointer = _selectionPointer; + if (terminalView == null || + selectionBase == null || + selectionBuffer == null || + selectionPointer == null || + !_scrollController.hasClients) { + return; + } + if (!identical(selectionBuffer, widget.terminal.buffer) || + !selectionBase.attached) { + _clearSelectionDrag(); + return; + } + final renderTerminal = terminalView.renderTerminal; + if (scroll) { + final position = _scrollController.position; + final target = + (position.pixels + renderTerminal.lineHeight * _scrollDirection) + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(); + if (target == position.pixels) { + _stopAutoScroll(); + } else { + position.jumpTo(target); + _selectionHasScrolled = true; + } + } + renderTerminal.selectCharacters( + renderTerminal.getOffset(selectionBase.offset), + selectionPointer, + ); + } + + void _handlePointerEnd(PointerEvent event) { + _updatePointerPosition(event); + if (!_mouseDrag.handleEnd(event, widget.terminal, _terminalView) && + event.pointer != _selectionPointerId) return; + if (_selectionHasScrolled) _scrollSelection(scroll: false); + _clearSelectionDrag(); + } + + void _clearSelectionDrag() { + _selectionPointerId = null; + _selectionBase?.dispose(); + _selectionBase = null; + _selectionBuffer = null; + _selectionPointer = null; + _selectionHasScrolled = false; + _stopAutoScroll(); + } + + void _stopAutoScroll() { + _selectionScrollTimer?.cancel(); + _selectionScrollTimer = null; + _scrollDirection = _noScroll; + } + + @override + void dispose() { + _mouseDrag.cancel(); + _clearSelectionDrag(); + _restoreMouseHandler(widget.terminal); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Listener( + onPointerDown: _handlePointerDown, + onPointerMove: _handlePointerMove, + onPointerUp: _handlePointerEnd, + onPointerHover: _updatePointerPosition, + onPointerCancel: _handlePointerEnd, + onPointerSignal: _updatePointerPosition, + onPointerPanZoomStart: _updatePointerPosition, + onPointerPanZoomUpdate: _updatePointerPosition, + onPointerPanZoomEnd: _updatePointerPosition, + child: TerminalView( + widget.terminal, + key: _terminalViewKey, + controller: widget.controller, + scrollController: _scrollController, + focusNode: widget.focusNode, + backgroundOpacity: widget.backgroundOpacity, + padding: widget.padding, + onSecondaryTapDown: widget.onSecondaryTapDown, + ), + ); } } diff --git a/flutter/test/terminal_mouse_handler_test.dart b/flutter/test/terminal_mouse_handler_test.dart index 3fae7f71d..62f19ff44 100644 --- a/flutter/test/terminal_mouse_handler_test.dart +++ b/flutter/test/terminal_mouse_handler_test.dart @@ -1,7 +1,33 @@ import 'package:flutter_hbb/models/terminal_mouse_handler.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:xterm/xterm.dart'; +const _terminalSize = Size(400, 120); + +Widget _terminalHarness( + Terminal terminal, + TerminalController controller, +) => + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: _terminalSize.width, + height: _terminalSize.height, + child: TerminalMouseInteraction( + terminal, + controller: controller, + ), + ), + ), + ); + +void _writeLines(Terminal terminal, int count) => terminal.write( + List.generate(count, (index) => 'line $index\r\n').join(), + ); + void main() { late Terminal terminal; late List output; @@ -111,4 +137,133 @@ void main() { isNull, ); }); + + testWidgets('dragging below scrolls and extends selection', (tester) async { + final controller = TerminalController(); + _writeLines(terminal, 80); + await tester.pumpWidget(_terminalHarness(terminal, controller)); + final terminalView = + tester.state(find.byType(TerminalView)); + final scrollController = terminalView.widget.scrollController!; + scrollController.jumpTo(0); + await tester.pump(); + final renderTerminal = terminalView.renderTerminal; + const localStart = Offset(20, 20); + final startCell = renderTerminal.getCellOffset(localStart); + final mouse = TestPointer(1, PointerDeviceKind.mouse); + final outside = Offset(20, renderTerminal.size.height); + await tester.handlePointerEventRecord([ + PointerEventRecord(Duration.zero, [ + mouse.down(renderTerminal.localToGlobal(localStart)), + mouse.move(renderTerminal.localToGlobal(outside)), + ]), + PointerEventRecord(const Duration(milliseconds: 150), [ + mouse.move( + renderTerminal.localToGlobal(outside + const Offset(1, 1)), + ), + mouse.up(), + ]), + ]); + + expect(scrollController.offset, greaterThan(0)); + expect(controller.selection!.begin, startCell); + expect(controller.selection!.end.y, greaterThan(startCell.y)); + final releasedOffset = scrollController.offset; + await tester.pump(const Duration(milliseconds: 100)); + expect(scrollController.offset, releasedOffset); + }); + + testWidgets('tmux mouse input is reported without local selection', + (tester) async { + final controller = TerminalController(); + terminal.write('\x1b[?1049h\x1b[?1002h\x1b[?1006hword'); + await tester.pumpWidget(_terminalHarness(terminal, controller)); + final renderTerminal = tester + .state(find.byType(TerminalView)) + .renderTerminal; + const wheel = Offset(120, 40); + await tester.sendEventToBinding( + PointerScrollEvent( + position: renderTerminal.localToGlobal(wheel), + scrollDelta: const Offset(0, 40), + ), + ); + await tester.pump(); + final wheelCell = renderTerminal.getCellOffset(wheel); + expect(output.first, '\x1b[<65;${wheelCell.x + 1};${wheelCell.y + 1}M'); + output.clear(); + final clickPosition = + renderTerminal.getOffset(const CellOffset(0, 0)) + const Offset(1, 1); + final clickCell = renderTerminal.getCellOffset(clickPosition); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.down(renderTerminal.localToGlobal(clickPosition)); + await mouse.up(); + await tester.pump(); + await mouse.down(renderTerminal.localToGlobal(clickPosition)); + await mouse.up(); + await tester.pump(kDoubleTapTimeout); + expect(output, [ + '\x1b[<0;${clickCell.x + 1};${clickCell.y + 1}M', + '\x1b[<0;${clickCell.x + 1};${clickCell.y + 1}m', + '\x1b[<0;${clickCell.x + 1};${clickCell.y + 1}M', + '\x1b[<0;${clickCell.x + 1};${clickCell.y + 1}m', + ]); + expect(controller.selection, isNull); + expect(controller.suspendedPointerInputs, isFalse); + output.clear(); + const start = Offset(40, 40); + const end = Offset(240, 80); + final startCell = renderTerminal.getCellOffset(start); + final endCell = renderTerminal.getCellOffset(end); + await mouse.down(renderTerminal.localToGlobal(start)); + await mouse.moveTo(renderTerminal.localToGlobal(end)); + await tester.pump(); + + expect(output, [ + '\x1b[<0;${startCell.x + 1};${startCell.y + 1}M', + '\x1b[<32;${endCell.x + 1};${endCell.y + 1}M', + ]); + expect(controller.selection, isNull); + await mouse.up(); + expect(output.last, '\x1b[<0;${endCell.x + 1};${endCell.y + 1}m'); + expect(controller.suspendedPointerInputs, isFalse); + }); + + testWidgets('tmux drag stays suppressed after mouse mode is disabled', + (tester) async { + final controller = TerminalController(); + terminal.write('\x1b[?1049h\x1b[?1002h\x1b[?1006hword'); + await tester.pumpWidget(_terminalHarness(terminal, controller)); + final renderTerminal = tester + .state(find.byType(TerminalView)) + .renderTerminal; + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + final start = renderTerminal.localToGlobal(const Offset(40, 40)); + final end = renderTerminal.localToGlobal(const Offset(240, 80)); + await mouse.down(start); + output.clear(); + terminal.write('\x1b[?1002l'); + await mouse.moveTo(end); + + expect(output, isEmpty); + expect(controller.selection, isNull); + expect(controller.suspendedPointerInputs, isTrue); + terminal.write('\x1b[?1002h'); + await mouse.moveTo(start); + expect(output, isEmpty); + expect(controller.selection, isNull); + await mouse.up(); + expect(output, isEmpty); + expect(controller.suspendedPointerInputs, isFalse); + + await mouse.down(start); + output.clear(); + terminal.write('\x1b[?1002l'); + await mouse.up(); + await tester.pump(kDoubleTapTimeout); + + expect(output, isEmpty); + expect(controller.selection, isNull); + expect(controller.suspendedPointerInputs, isFalse); + }); } From f1a06f67653029baa2a3959763ed52d7959c2bf6 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Thu, 20 Aug 2026 19:22:36 +0800 Subject: [PATCH 40/72] review rules --- AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index a32b940ad..21e631f1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,13 @@ * Do not extract or reshape existing code just to enable your new code; look for a mechanism that leaves existing lines untouched (e.g. hide/show an existing object instead of refactoring its construction into a helper for rebuilding). * Put new logic in self-contained functions in the module it belongs to (platform-specific logic in `src/platform/`, with `use` inside the function body to avoid churning shared import blocks). Call sites in shared files (`src/tray.rs`, `src/core_main.rs`, `src/server/connection.rs`, …) should be thin one-line hooks. +## Reviewing a PR + +* Review only what the diff introduces. Verify ownership with `gh pr diff` before reporting a finding — if the offending lines are untouched context, it is a pre-existing problem, not this PR's. +* List pre-existing problems in a separate section at the end, or leave out the ones that are not fatal. Never mix them into the findings the author has to fix. +* Before re-reviewing, read the author's reply comments. Do not re-raise items they declined on scope grounds. +* State a finding's consequence exactly: distinguish "the value is lost" from "the shortcut is inert but the value still saves". + ## Localization (`src/lang/*.rs`) Each file is a `HashMap`. Layout: From 798b73beb120d333902e4ff87a5e7587f343320c Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:52:31 +0800 Subject: [PATCH 41/72] Update common.rs (#15924) --- src/common.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common.rs b/src/common.rs index 9b22cbca2..6a8d39e27 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1088,7 +1088,7 @@ fn get_api_server_(api: String, custom: String) -> String { #[inline] pub fn is_public(url: &str) -> bool { let url = url.to_ascii_lowercase(); - url.contains("rustdesk.com/") || url.ends_with("rustdesk.com") + url.contains(".rustdesk.com") } pub fn get_udp_punch_enabled() -> bool { From c45f7d2dd222b0cb323ddfbd65d19dd7b8eb10b2 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Fri, 21 Aug 2026 01:25:51 +0800 Subject: [PATCH 42/72] refactor is_public --- src/common.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/common.rs b/src/common.rs index 6a8d39e27..e6993d074 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1087,8 +1087,15 @@ fn get_api_server_(api: String, custom: String) -> String { #[inline] pub fn is_public(url: &str) -> bool { - let url = url.to_ascii_lowercase(); - url.contains(".rustdesk.com") + let parsed = url::Url::parse(url) + .ok() + .filter(|parsed| parsed.has_host()) + .or_else(|| url::Url::parse(&format!("http://{url}")).ok()); + let Some(host) = parsed.as_ref().and_then(url::Url::host_str) else { + return false; + }; + let host = host.strip_suffix('.').unwrap_or(host); + host == "rustdesk.com" || host.ends_with(".rustdesk.com") } pub fn get_udp_punch_enabled() -> bool { @@ -2880,6 +2887,16 @@ mod tests { assert!(!is_public("rustdesk.comhello.com")); } + #[test] + fn test_is_public_matches_rustdesk_root_domain() { + assert!(is_public("rustdesk.com/")); + assert!(is_public("rustdesk.com:21117")); + assert!(is_public("api.rustdesk.com:21117")); + assert!(!is_public("hello-rustdesk.com")); + assert!(!is_public("api.rustdesk.com.evil.test")); + assert!(!is_public("https://rustdesk.com@evil.test")); + } + #[test] fn test_should_use_tcp_proxy_for_api_url() { assert!(should_use_tcp_proxy_for_api_url( From c78bdefc44838301c7588be582a9a10e18a6cdf8 Mon Sep 17 00:00:00 2001 From: fufesou Date: Fri, 21 Aug 2026 14:15:35 +0800 Subject: [PATCH 43/72] fix: dialog, trackpad speed, buttons (close -> ok, cancel) (#15918) * fix: dialog, trackpad speed, buttons (close -> ok, cancel) Signed-off-by: fufesou * fix(flutter): handle trackpad speed dialog submission - commit typed values from Enter and OK - validate input before saving - prevent duplicate submissions - surface save failures Signed-off-by: fufesou * fix(flutter): sync trackpad speed input and slider - handle trackpad speed submission from IME actions - update the slider when a valid speed is typed - cover Enter, OK, IME, and invalid input behavior Signed-off-by: fufesou --------- Signed-off-by: fufesou --- flutter/lib/common/widgets/dialog.dart | 118 +++++++++++++++--- .../lib/common/widgets/setting_widgets.dart | 48 +++++-- 2 files changed, 142 insertions(+), 24 deletions(-) diff --git a/flutter/lib/common/widgets/dialog.dart b/flutter/lib/common/widgets/dialog.dart index f009c051c..98d7f6b4b 100644 --- a/flutter/lib/common/widgets/dialog.dart +++ b/flutter/lib/common/widgets/dialog.dart @@ -1899,26 +1899,110 @@ customImageQualityDialog(SessionID sessionId, String id, FFI ffi) async { msgBoxCommon(ffi.dialogManager, 'Custom Image Quality', content, [btnClose]); } -trackpadSpeedDialog(SessionID sessionId, FFI ffi) async { - int initSpeed = ffi.inputModel.trackpadSpeed; +int? _validateTrackpadSpeed(String text) { + final speed = int.tryParse(text); + if (speed == null || speed < kMinTrackpadSpeed || speed > kMaxTrackpadSpeed) { + BotToast.showText( + text: + '${translate('Invalid format')}: $kMinTrackpadSpeed-$kMaxTrackpadSpeed', + contentColor: Colors.red, + ); + return null; + } + return speed; +} + +Future _saveTrackpadSpeed({ + required SessionID sessionId, + required FFI ffi, + required int initSpeed, + required int speed, +}) async { + if (speed == initSpeed) { + return; + } + await bind.sessionSetTrackpadSpeed(sessionId: sessionId, value: speed); + await ffi.inputModel.updateTrackpadSpeed(); +} + +void _showTrackpadSpeedSaveError(Object error, StackTrace stackTrace) { + debugPrint('Failed to save trackpad speed: $error'); + debugPrintStack(stackTrace: stackTrace); + BotToast.showText( + text: translate('Failed'), + contentColor: Colors.red, + ); +} + +List _trackpadSpeedDialogActions({ + required bool isSubmitting, + required VoidCallback close, + required VoidCallback submit, +}) { + return [ + dialogButton( + 'Cancel', + icon: Icon(Icons.close_rounded), + onPressed: isSubmitting ? null : close, + isOutline: true, + ), + dialogButton( + 'OK', + icon: Icon(Icons.done_rounded), + onPressed: isSubmitting ? null : submit, + ), + ]; +} + +void trackpadSpeedDialog(SessionID sessionId, FFI ffi) { + final initSpeed = ffi.inputModel.trackpadSpeed; final curSpeed = SimpleWrapper(initSpeed); - final btnClose = dialogButton('Close', onPressed: () async { - if (curSpeed.value <= kMaxTrackpadSpeed && - curSpeed.value >= kMinTrackpadSpeed && - curSpeed.value != initSpeed) { - await bind.sessionSetTrackpadSpeed( - sessionId: sessionId, value: curSpeed.value); - await ffi.inputModel.updateTrackpadSpeed(); + var speedText = initSpeed.toString(); + var isSubmitting = false; + ffi.dialogManager.show((setState, close, context) { + Future submit([String? submittedText]) async { + if (isSubmitting) { + return; + } + speedText = submittedText ?? speedText; + final speed = _validateTrackpadSpeed(speedText); + if (speed == null) { + return; + } + setState(() => isSubmitting = true); + try { + await _saveTrackpadSpeed( + sessionId: sessionId, + ffi: ffi, + initSpeed: initSpeed, + speed: speed, + ); + close(); + } catch (error, stackTrace) { + _showTrackpadSpeedSaveError(error, stackTrace); + setState(() => isSubmitting = false); + } } - ffi.dialogManager.dismissAll(); - }); - msgBoxCommon( - ffi.dialogManager, - 'Trackpad speed', - TrackpadSpeedWidget( - value: curSpeed, + + return CustomAlertDialog( + title: Text( + translate('Trackpad speed'), + style: TextStyle(fontSize: 21), ), - [btnClose]); + content: TrackpadSpeedWidget( + value: curSpeed, + onTextChanged: (text) => speedText = text, + onTextSubmitted: submit, + ), + actions: _trackpadSpeedDialogActions( + isSubmitting: isSubmitting, + close: close, + submit: submit, + ), + onSubmit: isSubmitting ? null : submit, + onCancel: isSubmitting ? null : close, + ); + }); } void deleteConfirmDialog(Function onSubmit, String title) async { diff --git a/flutter/lib/common/widgets/setting_widgets.dart b/flutter/lib/common/widgets/setting_widgets.dart index f3be77003..9449c3624 100644 --- a/flutter/lib/common/widgets/setting_widgets.dart +++ b/flutter/lib/common/widgets/setting_widgets.dart @@ -253,8 +253,18 @@ class TrackpadSpeedWidget extends StatefulWidget { final SimpleWrapper value; // If null, no debouncer will be applied. final Function(int)? onDebouncer; + final ValueChanged? onTextChanged; + // IME actions call TextField.onSubmitted without reaching the dialog's + // raw Enter handler, so the dialog needs a separate submission callback. + final ValueChanged? onTextSubmitted; - TrackpadSpeedWidget({Key? key, required this.value, this.onDebouncer}); + TrackpadSpeedWidget({ + Key? key, + required this.value, + this.onDebouncer, + this.onTextChanged, + this.onTextSubmitted, + }); @override TrackpadSpeedWidgetState createState() => TrackpadSpeedWidgetState(); @@ -276,6 +286,34 @@ class TrackpadSpeedWidgetState extends State { debouncerSpeed.setValue(value); } }); + widget.onTextChanged?.call(_controller.text); + } + + void updateTextValue(String text) { + widget.onTextChanged?.call(text); + final newValue = int.tryParse(text); + if (newValue == null || + newValue < kMinTrackpadSpeed || + newValue > kMaxTrackpadSpeed) { + return; + } + setState(() => value = newValue); + } + + void submitTextValue(String text) { + final onTextSubmitted = widget.onTextSubmitted; + if (onTextSubmitted != null) { + onTextSubmitted(text); + return; + } + if (widget.onTextChanged != null) { + return; + } + final newValue = int.tryParse(text); + if (newValue == null) { + return; + } + updateValue(newValue); } @override @@ -315,12 +353,8 @@ class TrackpadSpeedWidgetState extends State { controller: _controller, keyboardType: TextInputType.number, textAlign: TextAlign.center, - onSubmitted: (text) { - int? v = int.tryParse(text); - if (v != null) { - updateValue(v); - } - }, + onChanged: updateTextValue, + onSubmitted: submitTextValue, style: const TextStyle(fontSize: 13), decoration: InputDecoration( contentPadding: From 61ddade049dd048415e8fca9ba2c81cba13e5139 Mon Sep 17 00:00:00 2001 From: palmoni5 Date: Fri, 21 Aug 2026 12:07:25 +0300 Subject: [PATCH 44/72] fix(windows): restore keyboard focus when the cursor re-enters the remote image (#15880) * fix(windows): restore keyboard focus when the cursor re-enters the remote image On Windows the raw key focus node is unfocused on window blur and nothing requests it back, so returning to an already connected session left the keyboard dead until the remote image was clicked. Request focus from enterView(), gated on the window being active, the tab being selected and no blocking overlay, so a background window cannot grab system keys. enterOrLeave(true) is still driven by RawKeyFocusScope's onFocusChange, so it is not called twice. * fix(windows): refocus on window focus when the cursor already hovers the image Alt+Tab or a taskbar click returns focus without a PointerEnter, so enterView() cannot restore the keyboard. Reuse _cursorOverImage, gated on the selected tab and no blocking overlay. * refactor(windows): share one focus predicate for every requestFocus path The relative-mouse-mode restore on window focus could hand remote input to this page while a blocking dialog was up or the tab was not selected. --- flutter/lib/desktop/pages/remote_page.dart | 27 ++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 79f382249..3e98418b1 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -273,6 +273,11 @@ class _RemotePageState extends State tabState.tabs[selected].key == widget.id; } + // Every Windows requestFocus() must pass this, or a blocking dialog or an + // inactive tab could hand remote input to this page. + bool get _windowsCanFocusRemoteInput => + _isSelectedTab && _blockableOverlayState.middleBlocked.isFalse; + bool get _isMacOSKeyboardContextActive { return stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab; } @@ -513,6 +518,15 @@ class _RemotePageState extends State _queueMacOSKeyboardAfterFullScreen(allowHiddenLifecycle: true); } + // Refocus without PointerEnter: the cursor already hovers the image when + // focus returns (Alt+Tab, taskbar), so enterView() never fires again. + if (isWindows && + _cursorOverImage.value && + _windowsCanFocusRemoteInput && + !_rawKeyFocusNode.hasFocus) { + _rawKeyFocusNode.requestFocus(); + } + // Restore relative mouse mode constraints when window regains focus. if (_ffi.inputModel.relativeMouseMode.value) { if (isMacOS) { @@ -523,7 +537,7 @@ class _RemotePageState extends State _cursorOverImage.value = true; _macOSLocalFocusLost = false; } - } else { + } else if (!isWindows || _windowsCanFocusRemoteInput) { _rawKeyFocusNode.requestFocus(); } _ffi.inputModel.onWindowFocus(); @@ -835,7 +849,16 @@ class _RemotePageState extends State _macOSLocalFocusLost = false; stateGlobal.getInputSource(force: true); _syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true); - } else if (!isWindows) { + } else if (isWindows) { + // Blur unfocuses this node and nothing restores it, so the keyboard stayed + // dead until a click. Focus only while the window is really active, or a + // background window would grab system keys. onFocusChange does enterOrLeave. + if (!_isWindowBlur && + _windowsCanFocusRemoteInput && + !_rawKeyFocusNode.hasFocus) { + _rawKeyFocusNode.requestFocus(); + } + } else { if (!_rawKeyFocusNode.hasFocus) { _rawKeyFocusNode.requestFocus(); } From 92eb13717865a3b6ed03ff3f3824a53ee443e111 Mon Sep 17 00:00:00 2001 From: fufesou Date: Fri, 21 Aug 2026 21:20:39 +0800 Subject: [PATCH 45/72] feat(terminal): use platform-native copy and paste shortcuts (#15931) --- .../lib/models/terminal_copy_shortcut.dart | 66 +++++++++++++++++++ .../lib/models/terminal_mouse_handler.dart | 3 + flutter/test/terminal_mouse_handler_test.dart | 30 +++++++++ 3 files changed, 99 insertions(+) create mode 100644 flutter/lib/models/terminal_copy_shortcut.dart diff --git a/flutter/lib/models/terminal_copy_shortcut.dart b/flutter/lib/models/terminal_copy_shortcut.dart new file mode 100644 index 000000000..a526c16b4 --- /dev/null +++ b/flutter/lib/models/terminal_copy_shortcut.dart @@ -0,0 +1,66 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:xterm/xterm.dart'; + +const _controlShiftVPasteShortcut = SingleActivator( + LogicalKeyboardKey.keyV, + control: true, + shift: true, +); + +Future writeTerminalClipboard(String text) async { + try { + await Clipboard.setData(ClipboardData(text: text)); + } catch (error) { + debugPrint('[Terminal] Failed to write clipboard: $error'); + } +} + +Map? platformTerminalShortcuts() { + if (defaultTargetPlatform != TargetPlatform.linux) return null; + return { + for (final entry in defaultTerminalShortcuts.entries) + if (!_isControlVShortcut(entry.key)) entry.key: entry.value, + _controlShiftVPasteShortcut: + const PasteTextIntent(SelectionChangedCause.keyboard), + }; +} + +bool _isControlVShortcut(ShortcutActivator shortcut) => + shortcut is SingleActivator && + shortcut.trigger == LogicalKeyboardKey.keyV && + shortcut.control && + !shortcut.shift && + !shortcut.alt && + !shortcut.meta; + +FocusOnKeyEventCallback terminalCopyHandler( + Terminal terminal, + TerminalController controller, +) => + (_, event) { + if (!_isWindowsCopyShortcut(event)) return KeyEventResult.ignored; + final selection = controller.selection; + if (selection == null || selection.isCollapsed) { + return KeyEventResult.ignored; + } + if (event is KeyDownEvent) { + final text = terminal.buffer.getText(selection); + unawaited(writeTerminalClipboard(text)); + } + return KeyEventResult.handled; + }; + +bool _isWindowsCopyShortcut(KeyEvent event) { + final keyboard = HardwareKeyboard.instance; + return defaultTargetPlatform == TargetPlatform.windows && + (event is KeyDownEvent || event is KeyRepeatEvent) && + event.logicalKey == LogicalKeyboardKey.keyC && + keyboard.isControlPressed && + !keyboard.isShiftPressed && + !keyboard.isAltPressed && + !keyboard.isMetaPressed; +} diff --git a/flutter/lib/models/terminal_mouse_handler.dart b/flutter/lib/models/terminal_mouse_handler.dart index 6c5638793..76d84a2d4 100644 --- a/flutter/lib/models/terminal_mouse_handler.dart +++ b/flutter/lib/models/terminal_mouse_handler.dart @@ -4,6 +4,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'package:xterm/xterm.dart'; +import 'terminal_copy_shortcut.dart'; import 'terminal_mouse_drag_reporter.dart'; /// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift @@ -291,6 +292,8 @@ class _TerminalMouseInteractionState extends State { focusNode: widget.focusNode, backgroundOpacity: widget.backgroundOpacity, padding: widget.padding, + shortcuts: platformTerminalShortcuts(), + onKeyEvent: terminalCopyHandler(widget.terminal, widget.controller), onSecondaryTapDown: widget.onSecondaryTapDown, ), ); diff --git a/flutter/test/terminal_mouse_handler_test.dart b/flutter/test/terminal_mouse_handler_test.dart index 62f19ff44..04af45250 100644 --- a/flutter/test/terminal_mouse_handler_test.dart +++ b/flutter/test/terminal_mouse_handler_test.dart @@ -1,6 +1,8 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_hbb/models/terminal_mouse_handler.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:xterm/xterm.dart'; @@ -38,6 +40,34 @@ void main() { ..onOutput = output.add; }); + testWidgets('Linux Ctrl+V is not paste', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + try { + final messenger = tester.binding.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + SystemChannels.platform, + (_) async => {'text': 'clipboard'}, + ); + final controller = TerminalController(); + addTearDown(controller.dispose); + await tester.pumpWidget(_terminalHarness(terminal, controller)); + await tester.tap(find.byType(TerminalView)); + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyV); + final controlVOutput = List.of(output); + output.clear(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyV); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + expect(controlVOutput, ['\x16']); + expect(output, ['clipboard']); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + String? report( TerminalMouseButton button, [ TerminalMouseButtonState state = TerminalMouseButtonState.down, From 6eaac17ac5be69a81892300bc7b1535408f07fa1 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Sat, 22 Aug 2026 00:02:34 +0800 Subject: [PATCH 46/72] typo --- res/msi/preprocess.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/res/msi/preprocess.py b/res/msi/preprocess.py index ff0b5f510..4fde48bb0 100644 --- a/res/msi/preprocess.py +++ b/res/msi/preprocess.py @@ -61,7 +61,7 @@ def make_parser(): "--custom-arp", type=str, default="{}", - help='Custom arp properties, e.g. \'["Comments": {"msi": "ARPCOMMENTS", "v": "Remote control application."}]\'', + help='Custom arp properties, e.g. \'{"Comments": {"msi": "ARPCOMMENTS", "v": "Remote control application."}}\'', ) parser.add_argument( "-c", "--custom", action="store_true", help="Is custom client", default=False From cbf94402816bb0a20e7b1fa21b3ab7f524d65105 Mon Sep 17 00:00:00 2001 From: Saverio Miroddi Date: Fri, 21 Aug 2026 18:28:12 +0200 Subject: [PATCH 47/72] Prefer active X11 session display (#15933) * Prefer active X11 session display * Update linux.rs * fix(linux): keep the logind display only when it is a local one `get_display_from_session` returns the value pam_systemd was handed at session creation, and logind never updates it afterwards. That value is not always a usable local display: it can be qualified with this host (`myhost:0`), name an X forwarding endpoint (`localhost:10.0`), or be a bare `:`. Taking it unconditionally is worse than taking nothing, because a non-empty `self.display` suppresses every fallback below it, `get_display_by_user` and the `:0` default alike. The stripping at the end of `get_display_x11` does not save the last two cases either: it leaves `:` as is and turns `localhost:10.0` into a local looking `:10.0`, either of which is then exported as DISPLAY and leaves the session unreachable, where before this PR the host got a working `:0`. Strip this host so `myhost:0` is still accepted as `:0`, leave `localhost` in place, and require a display number after the colon. Anything else falls through to the existing chain. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TKJxvTT6NQDEcnkWBx5bLA * docs(agents): prefer a little duplication over a restructure The "Be minimally invasive" rules already ask for purely additive diffs, but not in the case where the addition would otherwise reshape an existing function so the two can share code. Repeating a few lines is the better diff there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TKJxvTT6NQDEcnkWBx5bLA --------- Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- AGENTS.md | 1 + src/platform/linux.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 21e631f1d..fe8b73ec7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,7 @@ * Prefer purely additive changes: layer new (`#[cfg]`-gated) blocks or new functions around existing code instead of restructuring it. The ideal diff for a fix adds lines and modifies/deletes none. * Do not extract or reshape existing code just to enable your new code; look for a mechanism that leaves existing lines untouched (e.g. hide/show an existing object instead of refactoring its construction into a helper for rebuilding). +* Accept a little duplication over a restructure. A new function that repeats a few lines of an existing one is a better diff than reshaping the original so both can share it. * Put new logic in self-contained functions in the module it belongs to (platform-specific logic in `src/platform/`, with `use` inside the function body to avoid churning shared import blocks). Call sites in shared files (`src/tray.rs`, `src/core_main.rs`, `src/server/connection.rs`, …) should be thin one-line hooks. ## Reviewing a PR diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 4fba6e669..f0979c2d1 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -2058,6 +2058,21 @@ mod desktop { sleep_millis(300); } + if self.display.is_empty() { + // logind stores the value pam_systemd was handed at session creation, which is not + // necessarily a local display: it can be qualified with this host (`myhost:0`) or + // name an X forwarding endpoint (`localhost:10.0`), and some setups record a bare + // `:`. Strip this host, then require a display number. `localhost` is deliberately + // left in place: a non-empty display here suppresses every fallback below, both + // `get_display_by_user` and the `:0` default, so anything not local must not pass. + let display = Self::get_display_from_session(&self.sid) + .replace(&hbb_common::whoami::hostname(), ""); + if display.strip_prefix(':').map_or(false, |number| { + number.starts_with(|c: char| c.is_ascii_digit()) + }) { + self.display = display; + } + } if self.display.is_empty() { self.display = Self::get_display_by_user(&self.username); } @@ -2070,6 +2085,34 @@ mod desktop { .replace("localhost", ""); } + fn get_display_from_session(session: &str) -> String { + if session.is_empty() { + return String::new(); + } + + match Command::new(CMD_LOGINCTL.as_str()) + .args(["show-session", "-p", "Display", session]) + .output() + { + Ok(output) if output.status.success() => String::from_utf8_lossy(&output.stdout) + .trim() + .strip_prefix("Display=") + .unwrap_or_default() + .to_owned(), + Ok(output) => { + log::debug!( + "Failed to get display for session {session}: {}", + output.status + ); + String::new() + } + Err(err) => { + log::debug!("Failed to get display for session {session}: {err}"); + String::new() + } + } + } + fn get_home(&mut self) { self.home = "".to_string(); From e266380ee91e2b8cc9d662b3a3ca1fd6f143d48b Mon Sep 17 00:00:00 2001 From: ben-leone Date: Fri, 21 Aug 2026 23:21:48 -0400 Subject: [PATCH 48/72] fix(appimage): keep the XDG default data dirs on XDG_DATA_DIRS (#15938) AppRun sets XDG_DATA_DIRS to "$APPDIR/usr/local/share:$APPDIR/usr/share:$XDG_DATA_DIRS". When the host leaves XDG_DATA_DIRS unset, the result contains no /usr/share, and setting the variable at all suppresses the XDG default of /usr/local/share:/usr/share. gdk-pixbuf 2.43+ (Arch, CachyOS, Gentoo, Fedora, openSUSE) no longer ships PNG, JPEG or WebP as loader modules; libgdk_pixbuf links libglycin and decodes them through it, and glycin discovers its loaders in $XDG_DATA_DIRS/glycin-loaders//conf.d/*.conf. With /usr/share missing, glycin finds none and every PNG decode inside the AppImage fails with "Unrecognized image file format". RustDesk sends remote cursors to flutter_custom_cursor as PNG, and that plugin returns nullptr from a std::string function when the decode fails, so the first non-default cursor of a session aborts the process: GdkPixbuf-CRITICAL **: gdk_pixbuf_copy: assertion 'GDK_IS_PIXBUF (pixbuf)' failed terminate called after throwing an instance of 'std::logic_error' what(): basic_string::_M_construct null not valid Debian and Ubuntu compile PNG straight into libgdk_pixbuf and never reach glycin, which is why this only affects non-Debian hosts. Append the two XDG defaults so they are present when the host does not provide them. They go last, so a session that sets XDG_DATA_DIRS properly keeps its own precedence, and appending is a no-op where those paths are already listed. Verified on CachyOS (gdk-pixbuf 2.44.7) against a stock 1.4.9 AppImage: with only this variable changed, a full remote session runs without crashing and renders remote cursors correctly. Refs #4565 #5457 #7013 #9164 #10563 #11499 #12257 #14305 #14405 #15625 Co-authored-by: Claude Opus 5 --- appimage/AppImageBuilder-aarch64.yml | 2 ++ appimage/AppImageBuilder-x86_64.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/appimage/AppImageBuilder-aarch64.yml b/appimage/AppImageBuilder-aarch64.yml index 1ccb51665..cc0c73111 100644 --- a/appimage/AppImageBuilder-aarch64.yml +++ b/appimage/AppImageBuilder-aarch64.yml @@ -76,6 +76,8 @@ AppDir: env: GIO_MODULE_DIR: /lib64/gio/modules:/usr/lib/aarch64-linux-gnu/gio/modules:$APPDIR/usr/lib/aarch64-linux-gnu/gio/modules GDK_BACKEND: x11 + # Append the XDG defaults: AppRun drops /usr/share when the host has XDG_DATA_DIRS unset, and gdk-pixbuf 2.43+ (Arch, Fedora) then finds no glycin loaders, so every PNG decode fails and the first remote cursor aborts the process. + XDG_DATA_DIRS: $APPDIR/usr/local/share:$APPDIR/usr/share:$XDG_DATA_DIRS:/usr/local/share:/usr/share APPDIR_LIBRARY_PATH: /lib64:/usr/lib/aarch64-linux-gnu:$APPDIR/lib/aarch64-linux-gnu:$APPDIR/lib/aarch64-linux-gnu/security:$APPDIR/lib/systemd:$APPDIR/usr/lib/aarch64-linux-gnu:$APPDIR/usr/lib/aarch64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gtk-3.0/3.0.0/immodules:$APPDIR/usr/lib/aarch64-linux-gnu/gtk-3.0/3.0.0/printbackends:$APPDIR/usr/lib/aarch64-linux-gnu/krb5/plugins/preauth:$APPDIR/usr/lib/aarch64-linux-gnu/libcanberra-0.30:$APPDIR/usr/lib/aarch64-linux-gnu/pulseaudio:$APPDIR/usr/lib/aarch64-linux-gnu/sasl2:$APPDIR/usr/lib/aarch64-linux-gnu/vdpau:$APPDIR/usr/share/rustdesk/lib:$APPDIR/lib/aarch64 GST_PLUGIN_PATH: /lib64/gstreamer-1.0:/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0 GST_PLUGIN_SYSTEM_PATH: /lib64/gstreamer-1.0:/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0 diff --git a/appimage/AppImageBuilder-x86_64.yml b/appimage/AppImageBuilder-x86_64.yml index 30b48e7da..0f0998a57 100644 --- a/appimage/AppImageBuilder-x86_64.yml +++ b/appimage/AppImageBuilder-x86_64.yml @@ -79,6 +79,8 @@ AppDir: env: GIO_MODULE_DIR: /lib64/gio/modules:/usr/lib/x86_64-linux-gnu/gio/modules:$APPDIR/usr/lib/x86_64-linux-gnu/gio/modules GDK_BACKEND: x11 + # Append the XDG defaults: AppRun drops /usr/share when the host has XDG_DATA_DIRS unset, and gdk-pixbuf 2.43+ (Arch, Fedora) then finds no glycin loaders, so every PNG decode fails and the first remote cursor aborts the process. + XDG_DATA_DIRS: $APPDIR/usr/local/share:$APPDIR/usr/share:$XDG_DATA_DIRS:/usr/local/share:/usr/share APPDIR_LIBRARY_PATH: /lib64:/usr/lib/x86_64-linux-gnu:$APPDIR/lib/x86_64-linux-gnu:$APPDIR/lib/x86_64-linux-gnu/security:$APPDIR/lib/systemd:$APPDIR/usr/lib/x86_64-linux-gnu:$APPDIR/usr/lib/x86_64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules:$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/printbackends:$APPDIR/usr/lib/x86_64-linux-gnu/krb5/plugins/preauth:$APPDIR/usr/lib/x86_64-linux-gnu/libcanberra-0.30:$APPDIR/usr/lib/x86_64-linux-gnu/pulseaudio:$APPDIR/usr/lib/x86_64-linux-gnu/sasl2:$APPDIR/usr/lib/x86_64-linux-gnu/vdpau:$APPDIR/usr/share/rustdesk/lib:$APPDIR/lib/x86_64 GST_PLUGIN_PATH: /lib64/gstreamer-1.0:/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0 GST_PLUGIN_SYSTEM_PATH: /lib64/gstreamer-1.0:/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0 From d5a7f67999ed5936f4553a64c0b3f638c9ff75b9 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Sat, 22 Aug 2026 12:25:35 +0800 Subject: [PATCH 49/72] fix appimage pixbuf crash --- appimage/AppImageBuilder-aarch64.yml | 9 +++++++-- appimage/AppImageBuilder-x86_64.yml | 9 +++++++-- flutter/pubspec.lock | 11 ++++++----- flutter/pubspec.yaml | 5 ++++- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/appimage/AppImageBuilder-aarch64.yml b/appimage/AppImageBuilder-aarch64.yml index cc0c73111..e7284f4f0 100644 --- a/appimage/AppImageBuilder-aarch64.yml +++ b/appimage/AppImageBuilder-aarch64.yml @@ -76,8 +76,13 @@ AppDir: env: GIO_MODULE_DIR: /lib64/gio/modules:/usr/lib/aarch64-linux-gnu/gio/modules:$APPDIR/usr/lib/aarch64-linux-gnu/gio/modules GDK_BACKEND: x11 - # Append the XDG defaults: AppRun drops /usr/share when the host has XDG_DATA_DIRS unset, and gdk-pixbuf 2.43+ (Arch, Fedora) then finds no glycin loaders, so every PNG decode fails and the first remote cursor aborts the process. - XDG_DATA_DIRS: $APPDIR/usr/local/share:$APPDIR/usr/share:$XDG_DATA_DIRS:/usr/local/share:/usr/share + # AppRun sets these to "$APPDIR/...:$XDG_DATA_DIRS", and setting them at all suppresses the XDG + # defaults, so a host that leaves them unset loses /usr/share and /etc/xdg. gdk-pixbuf 2.43+ + # (Arch, Fedora) then finds no glycin loaders and every PNG decode fails, aborting on the first + # remote cursor. The host value goes last: unset it expands to an empty element, which GLib + # resolves against the CWD, and that must not outrank the defaults below. + XDG_DATA_DIRS: $APPDIR/usr/local/share:$APPDIR/usr/share:/usr/local/share:/usr/share:$XDG_DATA_DIRS + XDG_CONFIG_DIRS: $APPDIR/etc/xdg:/etc/xdg:$XDG_CONFIG_DIRS APPDIR_LIBRARY_PATH: /lib64:/usr/lib/aarch64-linux-gnu:$APPDIR/lib/aarch64-linux-gnu:$APPDIR/lib/aarch64-linux-gnu/security:$APPDIR/lib/systemd:$APPDIR/usr/lib/aarch64-linux-gnu:$APPDIR/usr/lib/aarch64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gtk-3.0/3.0.0/immodules:$APPDIR/usr/lib/aarch64-linux-gnu/gtk-3.0/3.0.0/printbackends:$APPDIR/usr/lib/aarch64-linux-gnu/krb5/plugins/preauth:$APPDIR/usr/lib/aarch64-linux-gnu/libcanberra-0.30:$APPDIR/usr/lib/aarch64-linux-gnu/pulseaudio:$APPDIR/usr/lib/aarch64-linux-gnu/sasl2:$APPDIR/usr/lib/aarch64-linux-gnu/vdpau:$APPDIR/usr/share/rustdesk/lib:$APPDIR/lib/aarch64 GST_PLUGIN_PATH: /lib64/gstreamer-1.0:/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0 GST_PLUGIN_SYSTEM_PATH: /lib64/gstreamer-1.0:/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0 diff --git a/appimage/AppImageBuilder-x86_64.yml b/appimage/AppImageBuilder-x86_64.yml index 0f0998a57..5ec386c7d 100644 --- a/appimage/AppImageBuilder-x86_64.yml +++ b/appimage/AppImageBuilder-x86_64.yml @@ -79,8 +79,13 @@ AppDir: env: GIO_MODULE_DIR: /lib64/gio/modules:/usr/lib/x86_64-linux-gnu/gio/modules:$APPDIR/usr/lib/x86_64-linux-gnu/gio/modules GDK_BACKEND: x11 - # Append the XDG defaults: AppRun drops /usr/share when the host has XDG_DATA_DIRS unset, and gdk-pixbuf 2.43+ (Arch, Fedora) then finds no glycin loaders, so every PNG decode fails and the first remote cursor aborts the process. - XDG_DATA_DIRS: $APPDIR/usr/local/share:$APPDIR/usr/share:$XDG_DATA_DIRS:/usr/local/share:/usr/share + # AppRun sets these to "$APPDIR/...:$XDG_DATA_DIRS", and setting them at all suppresses the XDG + # defaults, so a host that leaves them unset loses /usr/share and /etc/xdg. gdk-pixbuf 2.43+ + # (Arch, Fedora) then finds no glycin loaders and every PNG decode fails, aborting on the first + # remote cursor. The host value goes last: unset it expands to an empty element, which GLib + # resolves against the CWD, and that must not outrank the defaults below. + XDG_DATA_DIRS: $APPDIR/usr/local/share:$APPDIR/usr/share:/usr/local/share:/usr/share:$XDG_DATA_DIRS + XDG_CONFIG_DIRS: $APPDIR/etc/xdg:/etc/xdg:$XDG_CONFIG_DIRS APPDIR_LIBRARY_PATH: /lib64:/usr/lib/x86_64-linux-gnu:$APPDIR/lib/x86_64-linux-gnu:$APPDIR/lib/x86_64-linux-gnu/security:$APPDIR/lib/systemd:$APPDIR/usr/lib/x86_64-linux-gnu:$APPDIR/usr/lib/x86_64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules:$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/printbackends:$APPDIR/usr/lib/x86_64-linux-gnu/krb5/plugins/preauth:$APPDIR/usr/lib/x86_64-linux-gnu/libcanberra-0.30:$APPDIR/usr/lib/x86_64-linux-gnu/pulseaudio:$APPDIR/usr/lib/x86_64-linux-gnu/sasl2:$APPDIR/usr/lib/x86_64-linux-gnu/vdpau:$APPDIR/usr/share/rustdesk/lib:$APPDIR/lib/x86_64 GST_PLUGIN_PATH: /lib64/gstreamer-1.0:/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0 GST_PLUGIN_SYSTEM_PATH: /lib64/gstreamer-1.0:/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0 diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 26fd3de72..5aae2440f 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -529,11 +529,12 @@ packages: flutter_custom_cursor: dependency: "direct main" description: - name: flutter_custom_cursor - sha256: "3850a32ac6de351ccc5e4286b6d94ff70c10abecd44479ea6c5aaea17264285d" - url: "https://pub.dev" - source: hosted - version: "0.0.4" + path: "." + ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e + resolved-ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e + url: "https://github.com/rustdesk-org/flutter_custom_cursor" + source: git + version: "0.0.3" flutter_gpu_texture_renderer: dependency: "direct main" description: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 64c5018f5..198036834 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -58,7 +58,10 @@ dependencies: git: url: https://github.com/rustdesk-org/rustdesk_desktop_multi_window freezed_annotation: ^2.0.3 - flutter_custom_cursor: ^0.0.4 + flutter_custom_cursor: + git: + url: https://github.com/rustdesk-org/flutter_custom_cursor + ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e window_size: git: url: https://github.com/21pages/flutter-desktop-embedding.git From a7deef02a20cdfeed7d90fe3bcccf8ada204e888 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 22 Aug 2026 17:49:00 +0800 Subject: [PATCH 50/72] fix(msi): keep only native ProductCode uninstall entry (#15891) * fix(msi): keep only native ProductCode uninstall entry Move installer state outside the Uninstall registry path, clean up legacy duplicate entries, and use the MSI ProductCode for updates and uninstalling. Signed-off-by: fufesou * fix(msi): harden update and uninstall handling - handle legacy EXE updates without an MSI ProductCode - propagate MsiExec uninstall failures - validate and XML-quote custom ARP values Signed-off-by: fufesou * fix(msi): validate registry state before update and uninstall Signed-off-by: fufesou * fix(msi): pass WindowsInstaller state to elevated sequence Signed-off-by: fufesou * fix(msi): block unsupported MSI-to-EXE upgrades - resolve native MSI state and ProductCode safely - suppress reboot while preserving MSI uninstall results - publish the resolved ARP install location - skip invalid unrelated MSI uninstall entries Signed-off-by: fufesou * fix(msi): fail uninstall when ProductCode is missing Prevent known MSI installations from falling back to EXE cleanup when the ProductCode cannot be resolved. Signed-off-by: fufesou * fix(msi): do not abort update on ARP version write failure Signed-off-by: fufesou --------- Signed-off-by: fufesou --- res/msi/Package/Components/Regs.wxs | 43 +++- .../Package/Fragments/AddRemoveProperties.wxs | 4 +- res/msi/Package/Package.wxs | 8 +- res/msi/preprocess.py | 136 +++-------- src/platform/windows.rs | 226 ++++++++++++++---- src/platform/windows/msi_registry.rs | 96 ++++++++ 6 files changed, 348 insertions(+), 165 deletions(-) create mode 100644 src/platform/windows/msi_registry.rs diff --git a/res/msi/Package/Components/Regs.wxs b/res/msi/Package/Components/Regs.wxs index 33d587b1e..25988f4a8 100644 --- a/res/msi/Package/Components/Regs.wxs +++ b/res/msi/Package/Components/Regs.wxs @@ -5,6 +5,23 @@ + + + + + + + + + + + + + + + + + @@ -40,17 +57,29 @@ - - - + + - - - - + + + + + + + + + + + + + + + + + diff --git a/res/msi/Package/Fragments/AddRemoveProperties.wxs b/res/msi/Package/Fragments/AddRemoveProperties.wxs index ac1d85a86..9f1460234 100644 --- a/res/msi/Package/Fragments/AddRemoveProperties.wxs +++ b/res/msi/Package/Fragments/AddRemoveProperties.wxs @@ -27,10 +27,12 @@ + + - + diff --git a/res/msi/Package/Package.wxs b/res/msi/Package/Package.wxs index e11756a65..f1109ef67 100644 --- a/res/msi/Package/Package.wxs +++ b/res/msi/Package/Package.wxs @@ -14,6 +14,7 @@ + @@ -22,10 +23,11 @@ - + + @@ -45,7 +47,9 @@ - + + + diff --git a/res/msi/preprocess.py b/res/msi/preprocess.py index 4fde48bb0..cd09e499f 100644 --- a/res/msi/preprocess.py +++ b/res/msi/preprocess.py @@ -12,6 +12,7 @@ import platform from pathlib import Path from itertools import chain import shutil +from xml.sax.saxutils import quoteattr g_indent_unit = "\t" g_version = "" @@ -54,7 +55,7 @@ def make_parser(): parser.add_argument( "--arp", action="store_true", - help="Is ARPSYSTEMCOMPONENT", + help="Deprecated; native MSI ARP registration is always used.", default=False, ) parser.add_argument( @@ -258,25 +259,19 @@ def gen_custom_dialog_bitmaps(): ) -def gen_custom_ARPSYSTEMCOMPONENT_False(args): +def gen_native_arp_properties(): def func(lines, index_start): indent = g_indent_unit * 2 lines_new = [] - lines_new.append( - f"{indent}\n" - ) - lines_new.append( - f'{indent}\n\n' - ) - lines_new.append( f"{indent}\n" ) for _, v in g_arpsystemcomponent.items(): if "msi" in v and "v" in v: lines_new.append( - f'{indent}\n' + f'{indent}\n' ) for i, line in enumerate(lines_new): @@ -291,94 +286,16 @@ def gen_custom_ARPSYSTEMCOMPONENT_False(args): ) -def get_folder_size(folder_path): - total_size = 0 - - folder = Path(folder_path) - for file in folder.glob("**/*"): - if file.is_file(): - total_size += file.stat().st_size - - return total_size - - -def gen_custom_ARPSYSTEMCOMPONENT_True(args, dist_dir): +def gen_install_state_values(): def func(lines, index_start): indent = g_indent_unit * 5 - lines_new = [] - lines_new.append( - f"{indent}\n" - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - installDate = datetime.datetime.now().strftime("%Y%m%d") - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - - # EstimatedSize in uninstall registry must be in KB. - estimated_size_bytes = get_folder_size(dist_dir) - estimated_size = max(1, (estimated_size_bytes + 1023) // 1024) - lines_new.append( - f'{indent}\n' - ) - - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - - vs = g_version.split(".") - major, minor, build = vs[0], vs[1], vs[2] - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - - lines_new.append( - f'{indent}\n' - ) - for k, v in g_arpsystemcomponent.items(): - if "v" in v: - t = v["t"] if "t" in v is None else "string" + for name, value in g_arpsystemcomponent.items(): + if "msi" not in value and "v" in value: + value_type = value.get("t", "string") lines_new.append( - f'{indent}\n' + f'{indent}\n' ) for i, line in enumerate(lines_new): @@ -387,24 +304,35 @@ def gen_custom_ARPSYSTEMCOMPONENT_True(args, dist_dir): return gen_content_between_tags( "Package/Components/Regs.wxs", - "", - "", + "", + "", func, ) -def gen_custom_ARPSYSTEMCOMPONENT(args, dist_dir): +def gen_custom_ARPSYSTEMCOMPONENT(args, _dist_dir): try: - custom_arp = json.loads(args.custom_arp) - g_arpsystemcomponent.update(custom_arp) - except json.JSONDecodeError as e: + custom_arp = dict(json.loads(args.custom_arp)) + except (json.JSONDecodeError, TypeError, ValueError) as e: print(f"Failed to decode custom arp: {e}") return False - if args.arp: - return gen_custom_ARPSYSTEMCOMPONENT_True(args, dist_dir) - else: - return gen_custom_ARPSYSTEMCOMPONENT_False(args) + if any(not isinstance(value, dict) for value in custom_arp.values()): + print("Custom arp entries must be objects.") + return False + + if any( + isinstance(value, dict) and value.get("msi") == "ARPSYSTEMCOMPONENT" + for value in custom_arp.values() + ): + print("ARPSYSTEMCOMPONENT is not allowed; native MSI ARP registration must remain visible.") + return False + + g_arpsystemcomponent.update(custom_arp) + + if not gen_native_arp_properties(): + return False + return gen_install_state_values() def gen_conn_type(args): def func(lines, index_start): diff --git a/src/platform/windows.rs b/src/platform/windows.rs index e32313987..998bd6bad 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -100,6 +100,7 @@ use winreg::{enums::*, RegKey}; mod acl; mod installer_handoff; mod installer_shell; +mod msi_registry; pub(crate) use acl::current_process_user_sid_string; pub use acl::{ set_path_permission, set_path_permission_for_portable_service_shmem_dir, @@ -119,6 +120,13 @@ pub const SET_FOREGROUND_WINDOW: &'static str = "SET_FOREGROUND_WINDOW"; const REG_NAME_INSTALL_DESKTOPSHORTCUTS: &str = "DESKTOPSHORTCUTS"; const REG_NAME_INSTALL_STARTMENUSHORTCUTS: &str = "STARTMENUSHORTCUTS"; pub const REG_NAME_INSTALL_PRINTER: &str = "PRINTER"; +const REG_NAME_MSI_PRODUCT_CODE: &str = "MsiProductCode"; +const REG_NAME_UNINSTALL_STRING: &str = "UninstallString"; +const REG_NAME_WINDOWS_INSTALLER: &str = "WindowsInstaller"; +const MSI_WINDOWS_INSTALLER_VALUE: u32 = 1; +const MSI_EXIT_SUCCESS_REBOOT_INITIATED: u32 = 1641; +const MSI_EXIT_SUCCESS_REBOOT_REQUIRED: u32 = 3010; +const HKLM_PREFIX: &str = "HKEY_LOCAL_MACHINE\\"; fn validate_install_app_name(app_name: &str) -> ResultType<()> { if app_name.is_empty() @@ -1305,6 +1313,11 @@ fn get_subkey(name: &str, wow: bool) -> String { } fn get_valid_subkey() -> String { + let app_name = crate::get_app_name(); + let subkey = format!("{HKLM_PREFIX}Software\\{app_name}\\InstallState\\{app_name}"); + if !get_reg_of(&subkey, "InstallLocation").is_empty() { + return subkey; + } let subkey = get_subkey(IS1, false); if !get_reg_of(&subkey, "InstallLocation").is_empty() { return subkey; @@ -1313,7 +1326,6 @@ fn get_valid_subkey() -> String { if !get_reg_of(&subkey, "InstallLocation").is_empty() { return subkey; } - let app_name = crate::get_app_name(); let subkey = get_subkey(&app_name, true); if !get_reg_of(&subkey, "InstallLocation").is_empty() { return subkey; @@ -1572,7 +1584,12 @@ fn get_after_install( } pub fn install_me(options: &str, path: String, silent: bool, debug: bool) -> ResultType<()> { - let uninstall_str = get_uninstall(false, false); + // MSI and EXE installations use different registry layouts, so MSI-to-EXE upgrades are not supported. + let (installed_subkey, _, _, _) = get_install_info(); + if get_windows_installer_state(&installed_subkey)? == Some(true) { + bail!("Cannot install the EXE package over an existing MSI installation"); + } + let uninstall_str = get_uninstall(false, false)?; let mut path = path.trim_end_matches('\\').to_owned(); let (subkey, _path, start_menu, exe) = get_default_install_info(); let mut exe = exe; @@ -1804,10 +1821,14 @@ fn get_before_uninstall(kill_self: bool) -> String { /// The `uninstall_printer` parameter determines whether the command to uninstall the remote printer /// is included in the generated uninstall script. If `uninstall_printer` is `false`, the printer /// related command is omitted from the script. -fn get_uninstall(kill_self: bool, uninstall_printer: bool) -> String { - let reg_uninstall_string = get_reg("UninstallString"); - if reg_uninstall_string.to_lowercase().contains("msiexec.exe") { - return reg_uninstall_string; +fn get_uninstall(kill_self: bool, uninstall_printer: bool) -> ResultType { + let (subkey, path, start_menu, _) = get_install_info(); + let installer_state = get_windows_installer_state(&subkey)?; + if let Some(product_code) = get_msi_product_code(&subkey, installer_state)? { + return Ok(build_msi_uninstall_command(&product_code)); + } + if installer_state == Some(true) { + bail!("MSI product code was not found in {subkey}"); } let mut uninstall_cert_cmd = "".to_string(); @@ -1820,8 +1841,7 @@ fn get_uninstall(kill_self: bool, uninstall_printer: bool) -> String { } } } - let (subkey, path, start_menu, _) = get_install_info(); - format!( + Ok(format!( " {before_uninstall} {uninstall_printer_cmd} @@ -1836,11 +1856,11 @@ fn get_uninstall(kill_self: bool, uninstall_printer: bool) -> String { before_uninstall=get_before_uninstall(kill_self), uninstall_amyuni_idd=get_uninstall_amyuni_idd(), app_name = crate::get_app_name(), - ) + )) } pub fn uninstall_me(kill_self: bool) -> ResultType<()> { - run_cmds(get_uninstall(kill_self, true), true, "uninstall") + run_cmds(get_uninstall(kill_self, true)?, true, "uninstall") } fn write_vbs(cmds: String, tip: &str) -> ResultType { @@ -3389,6 +3409,8 @@ pub fn update_me(debug: bool) -> ResultType<()> { if !is_installed { bail!("{} is not installed.", &app_name); } + let is_msi = is_msi_installed().ok(); + let reg_msi_key = get_reg_msi_key(&subkey, is_msi)?; let app_exe_name = &format!("{}.exe", &app_name); // NOTE: The pids below are matched by command line, which can silently come @@ -3439,8 +3461,6 @@ pub fn update_me(debug: bool) -> ResultType<()> { // Use the icon in the previous installation directory if possible. let display_icon = get_custom_icon("", &exe).unwrap_or(exe.to_string()); - let is_msi = is_msi_installed().ok(); - fn get_reg_cmd( subkey: &str, is_msi: Option, @@ -3486,18 +3506,10 @@ reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size} &version_build, size, ); - let reg_cmd_msi = if let Some(reg_msi_key) = get_reg_msi_key(&subkey, is_msi) { - get_reg_cmd( - ®_msi_key, - is_msi, - &display_icon, - &version, - &build_date, - &version_major, - &version_minor, - &version_build, - size, - ) + let reg_cmd_msi = if let Some(reg_msi_key) = ®_msi_key { + // This is best-effort: failure may leave a stale version in the Windows app list, + // but should not interrupt the update. + format!("reg add {reg_msi_key} /f /v DisplayVersion /t REG_SZ /d \"{version}\"") } else { "".to_owned() }; @@ -3621,34 +3633,147 @@ taskkill /F /IM {app_name}.exe{filter} Ok(()) } -fn get_reg_msi_key(subkey: &str, is_msi: Option) -> Option { +fn normalize_msi_product_code(value: &str) -> Option { + let value = value.trim().trim_matches('"'); + let value = value.strip_prefix('{')?.strip_suffix('}')?; + let product_code = uuid::Uuid::parse_str(value).ok()?; + Some(format!("{{{}}}", product_code.hyphenated()).to_uppercase()) +} + +fn build_msi_uninstall_command(product_code: &str) -> String { + format!( + "set \"RUSTDESK_MSI_EXIT_CODE=\"\n\ +MsiExec.exe /X {product_code} /norestart REBOOT=ReallySuppress\n\ +set \"RUSTDESK_MSI_EXIT_CODE=%ERRORLEVEL%\"\n\ +if \"%RUSTDESK_MSI_EXIT_CODE%\"==\"{MSI_EXIT_SUCCESS_REBOOT_REQUIRED}\" echo MSI uninstall succeeded with a reboot recommendation; continuing without reboot.\n\ +if \"%RUSTDESK_MSI_EXIT_CODE%\"==\"{MSI_EXIT_SUCCESS_REBOOT_INITIATED}\" echo MSI uninstall succeeded with a reboot request; continuing without forcing reboot.\n\ +if not \"%RUSTDESK_MSI_EXIT_CODE%\"==\"0\" if not \"%RUSTDESK_MSI_EXIT_CODE%\"==\"{MSI_EXIT_SUCCESS_REBOOT_REQUIRED}\" if not \"%RUSTDESK_MSI_EXIT_CODE%\"==\"{MSI_EXIT_SUCCESS_REBOOT_INITIATED}\" exit /b %RUSTDESK_MSI_EXIT_CODE%\n\ +ver > nul" + ) +} + +fn get_reg_string_of(subkey: &str, name: &str) -> ResultType> { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let path = subkey.strip_prefix(HKLM_PREFIX).unwrap_or(subkey); + let key = match hklm.open_subkey(path) { + Ok(key) => key, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => bail!("Failed to open registry key {subkey}: {err}"), + }; + match key.get_value::(name) { + Ok(value) => Ok(Some(value)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => bail!("Failed to read {name} from registry key {subkey}: {err}"), + } +} + +fn get_windows_installer_state(subkey: &str) -> ResultType> { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let path = subkey.strip_prefix(HKLM_PREFIX).unwrap_or(subkey); + let key = match hklm.open_subkey(path) { + Ok(key) => key, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => bail!("Failed to open registry key {subkey}: {err}"), + }; + match key.get_value::(REG_NAME_WINDOWS_INSTALLER) { + Ok(value) => Ok(Some(value == MSI_WINDOWS_INSTALLER_VALUE)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => bail!("Failed to read {REG_NAME_WINDOWS_INSTALLER} from {subkey}: {err}"), + } +} + +fn parse_msi_product_code_from_uninstall_string( + uninstall_string: &str, + subkey: &str, +) -> ResultType> { + if !uninstall_string + .to_ascii_lowercase() + .contains("msiexec.exe") + { + return Ok(None); + } + let start = uninstall_string + .rfind('{') + .ok_or_else(|| anyhow!("MSI uninstall string has no product code in {subkey}"))?; + let end = uninstall_string + .rfind('}') + .ok_or_else(|| anyhow!("MSI uninstall string has no product code in {subkey}"))?; + if start >= end { + bail!("Invalid MSI uninstall string in {subkey}"); + } + let product_code = uninstall_string + .get(start..=end) + .and_then(normalize_msi_product_code) + .ok_or_else(|| anyhow!("Invalid MSI uninstall string in {subkey}"))?; + Ok(Some(product_code)) +} + +fn get_msi_product_code(subkey: &str, installer_state: Option) -> ResultType> { + if installer_state == Some(false) { + return Ok(None); + } + let product_code = get_reg_string_of(subkey, REG_NAME_MSI_PRODUCT_CODE)?; + if let Some(product_code) = product_code.filter(|value| !value.is_empty()) { + return normalize_msi_product_code(&product_code) + .map(Some) + .ok_or_else(|| anyhow!("Invalid MSI product code in {subkey}")); + } + + let uninstall_string = + get_reg_string_of(subkey, REG_NAME_UNINSTALL_STRING)?.unwrap_or_default(); + match parse_msi_product_code_from_uninstall_string(&uninstall_string, subkey)? { + Some(product_code) => Ok(Some(product_code)), + None if installer_state == Some(true) => { + msi_registry::find_product_code(&crate::get_app_name()) + } + None => Ok(None), + } +} + +fn is_msi_uninstall_entry_in_view(subkey: &str, wow: bool, app_name: &str) -> ResultType { + let flags = KEY_READ + | if wow { + KEY_WOW64_32KEY + } else { + KEY_WOW64_64KEY + }; + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let path = subkey.strip_prefix(HKLM_PREFIX).unwrap_or(subkey); + let key = match hklm.open_subkey_with_flags(path, flags) { + Ok(key) => key, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(anyhow!("Failed to open registry key {subkey}: {err}")), + }; + msi_registry::is_matching_entry(&key, app_name, subkey) +} + +fn get_msi_uninstall_subkey(product_code: &str) -> ResultType { + let app_name = crate::get_app_name(); + let subkey = get_subkey(product_code, false); + if is_msi_uninstall_entry_in_view(&subkey, false, &app_name)? { + return Ok(subkey); + } + if is_msi_uninstall_entry_in_view(&subkey, true, &app_name)? { + return Ok(get_subkey(product_code, true)); + } + bail!("Matching native MSI uninstall entry {product_code} was not found") +} + +fn get_reg_msi_key(subkey: &str, is_msi: Option) -> ResultType> { // Only proceed if it's a custom client and MSI is installed. // `is_msi.unwrap_or(true)` is intentional: subsequent code validates the registry, // hence no early return is required upon MSI detection failure. if !(crate::common::is_custom_client() && is_msi.unwrap_or(true)) { - return None; + return Ok(None); } - // Get the uninstall string from registry - let uninstall_string = get_reg_of(subkey, "UninstallString"); - if uninstall_string.is_empty() { - return None; - } - - // Find the product code (GUID) in the uninstall string - // Handle both quoted and unquoted GUIDs: /X {GUID} or /X "{GUID}" - let start = uninstall_string.rfind('{')?; - let end = uninstall_string.rfind('}')?; - if start >= end { - return None; - } - let product_code = &uninstall_string[start..=end]; - - // Build the MSI registry key path - let pos = subkey.rfind('\\')?; - let reg_msi_key = format!("{}{}", &subkey[..=pos], product_code); - - Some(reg_msi_key) + let Some(product_code) = get_msi_product_code(subkey, is_msi)? else { + if is_msi == Some(true) { + bail!("MSI product code was not found in {subkey}"); + } + return Ok(None); + }; + Ok(Some(get_msi_uninstall_subkey(&product_code)?)) } // Double confirm the process name @@ -4422,12 +4547,11 @@ fn get_pids>(name: S) -> ResultType> { } pub fn is_msi_installed() -> std::io::Result { + let (subkey, _, _, _) = get_install_info(); let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); - let uninstall_key = hklm.open_subkey(format!( - "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{}", - crate::get_app_name() - ))?; - Ok(1 == uninstall_key.get_value::("WindowsInstaller")?) + let install_key = hklm.open_subkey(subkey.strip_prefix(HKLM_PREFIX).unwrap_or(&subkey))?; + Ok(MSI_WINDOWS_INSTALLER_VALUE + == install_key.get_value::(REG_NAME_WINDOWS_INSTALLER)?) } pub fn is_cur_exe_the_installed() -> bool { diff --git a/src/platform/windows/msi_registry.rs b/src/platform/windows/msi_registry.rs new file mode 100644 index 000000000..ef08f17fb --- /dev/null +++ b/src/platform/windows/msi_registry.rs @@ -0,0 +1,96 @@ +use super::{ + normalize_msi_product_code, ResultType, MSI_WINDOWS_INSTALLER_VALUE, REG_NAME_WINDOWS_INSTALLER, +}; +use hbb_common::{anyhow::anyhow, bail, log}; +use std::collections::BTreeSet; +use winreg::{enums::*, RegKey}; + +const REG_NAME_DISPLAY_NAME: &str = "DisplayName"; +const UNINSTALL_SUBKEY: &str = "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall"; + +pub(super) fn find_product_code(app_name: &str) -> ResultType> { + let product_codes = find_product_codes_in_view(app_name, false)? + .into_iter() + .chain(find_product_codes_in_view(app_name, true)?) + .collect::>(); + let mut product_codes = product_codes.into_iter(); + let product_code = product_codes.next(); + if product_codes.next().is_some() { + bail!("Multiple native MSI uninstall entries were found for {app_name}"); + } + Ok(product_code) +} + +fn find_product_codes_in_view(app_name: &str, wow: bool) -> ResultType> { + let flags = KEY_READ + | if wow { + KEY_WOW64_32KEY + } else { + KEY_WOW64_64KEY + }; + let view_name = if wow { "32-bit" } else { "64-bit" }; + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let uninstall_key = match hklm.open_subkey_with_flags(UNINSTALL_SUBKEY, flags) { + Ok(uninstall_key) => uninstall_key, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => bail!("Failed to open {view_name} MSI uninstall registry: {err}"), + }; + let mut matches = Vec::new(); + + for key_name in uninstall_key.enum_keys() { + let key_name = match key_name { + Ok(key_name) => key_name, + Err(err) => { + log::warn!("Skipping unreadable {view_name} MSI uninstall key name: {err}"); + continue; + } + }; + let Some(product_code) = normalize_msi_product_code(&key_name) else { + continue; + }; + let is_match = uninstall_key + .open_subkey_with_flags(&key_name, flags) + .map_err(|err| { + anyhow!("Failed to open {view_name} MSI uninstall entry {key_name}: {err}") + }) + .and_then(|entry| is_matching_entry(&entry, app_name, &key_name)); + if scanned_entry_matches(is_match) { + matches.push(product_code); + } + } + + Ok(matches) +} + +pub(super) fn scanned_entry_matches(result: ResultType) -> bool { + match result { + Ok(is_match) => is_match, + Err(err) => { + log::warn!("Skipping invalid MSI uninstall entry: {err}"); + false + } + } +} + +pub(super) fn is_matching_entry( + entry: &RegKey, + app_name: &str, + key_name: &str, +) -> ResultType { + match entry.get_value::(REG_NAME_WINDOWS_INSTALLER) { + Ok(value) if value == MSI_WINDOWS_INSTALLER_VALUE => {} + Ok(_) => return Ok(false), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(err) => bail!( + "Failed to read {REG_NAME_WINDOWS_INSTALLER} from MSI uninstall entry {key_name}: {err}" + ), + } + + match entry.get_value::(REG_NAME_DISPLAY_NAME) { + Ok(display_name) => Ok(display_name.eq_ignore_ascii_case(app_name)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => bail!( + "Failed to read {REG_NAME_DISPLAY_NAME} from MSI uninstall entry {key_name}: {err}" + ), + } +} From 7423dced37845c48093b6f71bd82e4a16cc8c79d Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:50:10 +0800 Subject: [PATCH 51/72] Update reference from AGENTS.md to @AGENTS.md --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c31706425..43c994c2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md +@AGENTS.md From a3bab27a2a7875ae910293f474416ce8d9151f40 Mon Sep 17 00:00:00 2001 From: Robert Markovski <5818108+Roshan931@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:21:11 +0200 Subject: [PATCH 52/72] fix: Show My Cursor freezes in View Only mode when remote user mo... (#15936) --- flutter/lib/models/input_model.dart | 5 +++++ flutter/lib/models/model.dart | 1 + 2 files changed, 6 insertions(+) diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index a701e6e53..6ea23c2c9 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -1787,6 +1787,11 @@ class InputModel { } bool _checkPeerControlProtected(double x, double y) { + if (isViewOnly && showMyCursor) { + lastMousePos = ui.Offset(x, y); + return false; + } + final cursorModel = parent.target!.cursorModel; if (cursorModel.isPeerControlProtected) { lastMousePos = ui.Offset(x, y); diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 129f67dea..bd564ba3b 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -2498,6 +2498,7 @@ class CanvasModel with ChangeNotifier { } void updateLocalCursor(double x, double y) { + if (parent.target?.ffiModel.viewOnly == true) return; // If keyboard is not permitted, do not move cursor when mouse is moving. if (parent.target != null && parent.target!.ffiModel.keyboard) { // Draw cursor if is not desktop. From f07b6e2338b03ef24e88d5063747da616c2c9f1c Mon Sep 17 00:00:00 2001 From: jhertel <2607200+jhertel@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:22:03 +0200 Subject: [PATCH 53/72] Correct Danish spelling, language and translation (#15943) * Update da.rs Corrected spelling, language and translation mistakes. * Update da.rs Missed one correction. --- src/lang/da.rs | 136 ++++++++++++++++++++++++------------------------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/src/lang/da.rs b/src/lang/da.rs index 4cf509e98..38447d724 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -335,24 +335,24 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Scale adaptive", "Adaptiv skalering"), ("General", "Generelt"), ("Security", "Sikkerhed"), - ("Theme", "Thema"), - ("Dark Theme", "Mørk Tema"), - ("Light Theme", "Lys Tema"), + ("Theme", "Tema"), + ("Dark Theme", "Mørkt tema"), + ("Light Theme", "Lyst tema"), ("Dark", "Mørk"), ("Light", "Lys"), - ("Follow System", "Følg System"), + ("Follow System", "Følg system"), ("Enable hardware codec", "Aktivér hardware-codec"), ("Unlock Security Settings", "Lås op for sikkerhedsindstillinger"), ("Enable audio", "Aktivér Lyd"), - ("Unlock Network Settings", "Lås op for Netværksindstillinger"), + ("Unlock Network Settings", "Lås op for netværksindstillinger"), ("Server", "Server"), - ("Direct IP Access", "Direkte IP Adgang"), + ("Direct IP Access", "Direkte IP-adgang"), ("Proxy", "Proxy"), ("Apply", "Anvend"), ("Disconnect all devices?", "Afbryd alle enheder?"), ("Clear", "Nulstil"), ("Audio Input Device", "Lydindgangsenhed"), - ("Use IP Whitelisting", "Brug IP Whitelisting"), + ("Use IP Whitelisting", "Brug IP-hvidlistning"), ("Network", "Netværk"), ("Pin Toolbar", "Fastgør værktøjslinjen"), ("Unpin Toolbar", "Frigiv værktøjslinjen"), @@ -377,10 +377,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Full Access", "Fuld adgang"), ("Screen Share", "Skærmdeling"), ("ubuntu-21-04-required", "Wayland kræver Ubuntu version 21.04 eller nyere."), - ("wayland-requires-higher-linux-version", "Wayland kræver en højere version af Linux distro. Prøv venligst X11 desktop eller skift dit OS."), + ("wayland-requires-higher-linux-version", "Wayland kræver en højere version af Linux-distro. Prøv venligst X11-desktoppen eller skift dit OS."), ("xdp-portal-unavailable", "Skærmoptagelse via Wayland mislykkedes. XDG Desktop Portal kan være gået ned eller er utilgængelig. Prøv at genstarte den med `systemctl --user restart xdg-desktop-portal`."), ("JumpLink", "JumpLink"), - ("Please Select the screen to be shared(Operate on the peer side).", "Vælg venligst den skærm, der skal deles (Betjen på modtagersiden)."), + ("Please Select the screen to be shared(Operate on the peer side).", "Vælg venligst den skærm, der skal deles (betjen på modtagersiden)."), ("Show RustDesk", "Vis RustDesk"), ("This PC", "Denne PC"), ("or", "eller"), @@ -392,32 +392,32 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Please wait for the remote side to accept your session request...", "Vent venligst på at fjernklienten accepterer din sessionsforespørgsel..."), ("One-time Password", "Engangskode"), ("Use one-time password", "Brug engangskode"), - ("One-time password length", "Engangskode længde"), + ("One-time password length", "Længde af engangskode"), ("Request access to your device", "Efterspørg adgang til din enhed"), ("Hide connection management window", "Skjul forbindelseshåndteringsvindue"), ("hide_cm_tip", "Tillad at skjule, hvis der kun forbindes ved brug af midlertidige og permanente adgangskoder"), - ("wayland_experiment_tip", "Wayland understøttelse er stadigvæk under udvikling. Hvis du har brug for ubemandet adgang, bedes du bruge X11."), + ("wayland_experiment_tip", "Wayland-understøttelse er stadigvæk under udvikling. Hvis du har brug for ubemandet adgang, bedes du bruge X11."), ("Right click to select tabs", "Højreklik for at vælge faner"), ("Skipped", "Sprunget over"), ("Add to address book", "Tilføj til adressebog"), ("Group", "Gruppe"), ("Search", "Søg"), ("Closed manually by web console", "Lukket ned manuelt af webkonsollen"), - ("Local keyboard type", "Lokal tastatur type"), - ("Select local keyboard type", "Vælg lokal tastatur type"), - ("software_render_tip", "Hvis du bruger et Nvidia grafikkort på Linux, og fjernskrivebordsvinduet lukker ned med det samme efter forbindelsen er oprettet, kan det hjælpe at skifte til Nouveau open-source driveren, og aktivere software rendering. Et genstart af RustDesk er nødvendigt."), - ("Always use software rendering", "Brug altid software rendering"), + ("Local keyboard type", "Type af lokalt tastatur"), + ("Select local keyboard type", "Vælg typen af lokalt tastatur"), + ("software_render_tip", "Hvis du bruger et Nvidia-grafikkort på Linux, og fjernskrivebordsvinduet lukker ned med det samme efter forbindelsen er oprettet, kan det hjælpe at skifte til Nouveau open source-driveren, og aktivere software-rendering. En genstart af RustDesk er nødvendig."), + ("Always use software rendering", "Brug altid software-rendering"), ("config_input", "For at styre fjernskrivebordet med tastaturet, skal du give Rustdesk rettigheder til at optage tastetryk"), ("config_microphone", "For at tale sammen over fjernstyring, skal du give RustDesk rettigheder til at optage lyd"), ("request_elevation_tip", "Du kan også spørge om elevationsrettigheder, hvis der er nogen i nærheden af fjernenheden."), ("Wait", "Vent"), ("Elevation Error", "Elevationsfejl"), - ("Ask the remote user for authentication", "Spørg fjernbrugeren for godkendelse"), + ("Ask the remote user for authentication", "Bed fjernbrugeren om at godkende"), ("Choose this if the remote account is administrator", "Vælg dette hvis fjernbrugeren er en administrator"), ("Transmit the username and password of administrator", "Send brugernavnet og adgangskoden på administratoren"), - ("still_click_uac_tip", "Kræver stadigvæk at fjernbrugeren skal trykke OK på UAC vinduet ved kørsel af RustDesk."), - ("Request Elevation", "Efterspørger elevation"), - ("wait_accept_uac_tip", "Vent venligst på at fjernbrugeren accepterer UAC dialog forespørgslen."), + ("still_click_uac_tip", "Kræver stadigvæk at fjernbrugeren skal trykke OK på UAC-vinduet ved kørsel af RustDesk."), + ("Request Elevation", "Efterspørg elevation"), + ("wait_accept_uac_tip", "Vent venligst på at fjernbrugeren accepterer UAC-dialog-forespørgslen."), ("Elevate successfully", "Elevation lykkedes"), ("uppercase", "store bogstaver"), ("lowercase", "små bogstaver"), @@ -441,13 +441,13 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Voice call", "Stemmeopkald"), ("Text chat", "Tekstchat"), ("Stop voice call", "Stop stemmeopkald"), - ("relay_hint_tip", "Det kan ske, at det ikke er muligt at forbinde direkte; du kan forsøge at forbinde via en relay-server. Derudover, hvis du ønsker at bruge en relay-server på dit første forsøg, kan du tilføje \"/r\" efter ID'et, eller bruge valgmuligheden \"Forbind altid via relay-server\" i fanen for seneste sessioner, hvis den findes."), + ("relay_hint_tip", "Det er måske ikke muligt at forbinde direkte; du kan forsøge at forbinde via en relay-server. Hvis du ønsker at bruge en relay-server på dit første forsøg, kan du tilføje \"/r\" efter ID'et, eller bruge valgmuligheden \"Forbind altid via relay-server\" i fanen for seneste sessioner, hvis den findes."), ("Reconnect", "Genopret"), ("Codec", "Codec"), ("Resolution", "Opløsning"), ("No transfers in progress", "Ingen overførsler i gang"), - ("Set one-time password length", "Sæt engangsadgangskode længde"), - ("RDP Settings", "RDP indstillinger"), + ("Set one-time password length", "Sæt længde af engangsadgangskode"), + ("RDP Settings", "RDP-indstillinger"), ("Sort by", "Sortér efter"), ("New Connection", "Ny forbindelse"), ("Restore", "Gendan"), @@ -455,16 +455,16 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Maximize", "Maksimér"), ("Your Device", "Din enhed"), ("empty_recent_tip", "Ups, ingen seneste sessioner!\nTid til at oprette en ny."), - ("empty_favorite_tip", "Ingen yndlings modparter endnu?\nLad os finde én at forbinde til, og tilføje den til dine favoritter!"), - ("empty_lan_tip", "Åh nej, det ser ud til, at vi ikke kunne finde nogen modparter endnu."), - ("empty_address_book_tip", "Åh nej, det ser ud til at der ikke er nogle modparter der er tilføjet til din adressebog."), - ("Empty Username", "Tom brugernavn"), + ("empty_favorite_tip", "Ingen yndlingsmodparter endnu?\nLad os finde én at forbinde til, og tilføje den til dine favoritter!"), + ("empty_lan_tip", "Åh nej, det ser ud til, at vi ikke har kunnet finde nogen modparter endnu."), + ("empty_address_book_tip", "Åh nej, det ser ud til at der ikke er nogen modparter, der er tilføjet til din adressebog."), + ("Empty Username", "Tomt brugernavn"), ("Empty Password", "Tom adgangskode"), ("Me", "Mig"), ("identical_file_tip", "Denne fil er identisk med modpartens."), ("show_monitors_tip", "Vis skærme i værktøjsbjælken"), ("View Mode", "Visningstilstand"), - ("verify_rustdesk_password_tip", "Bekræft RustDesk adgangskode"), + ("verify_rustdesk_password_tip", "Bekræft RustDesk-adgangskode"), ("No need to elevate", "Ingen grund til at elevere"), ("System Sound", "Systemlyd"), ("Default", "Standard"), @@ -478,12 +478,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("resolution_custom_tip", "Bruger-tilpasset skærmopløsning"), ("Collapse toolbar", "Skjul værktøjsbjælke"), ("Accept and Elevate", "Acceptér og elevér"), - ("accept_and_elevate_btn_tooltip", "Acceptér forbindelsen og elevér UAC tilladelser"), + ("accept_and_elevate_btn_tooltip", "Acceptér forbindelsen og elevér UAC-tilladelser"), ("clipboard_wait_response_timeout_tip", "Tiden for at vente på en kopieringsforespørgsel udløb"), ("Incoming connection", "Indgående forbindelse"), ("Outgoing connection", "Udgående forbindelse"), ("Exit", "Afslut"), - ("Open", "Åben"), + ("Open", "Åbn"), ("logout_tip", "Er du sikker på at du vil logge af?"), ("Service", "Tjeneste"), ("Start", "Start"), @@ -492,7 +492,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Sync with recent sessions", "Synkronisér med tidligere sessioner"), ("Sort tags", "Sortér nøgleord"), ("Open connection in new tab", "Åbn forbindelse i en ny fane"), - ("Move tab to new window", "Flyt fane i et nyt vindue"), + ("Move tab to new window", "Flyt fane til et nyt vindue"), ("Can not be empty", "Kan ikke være tom"), ("Already exists", "Findes allerede"), ("Change Password", "Skift adgangskode"), @@ -507,14 +507,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("synced_peer_readded_tip", "Enhederne, som var til stede i de seneste sessioner, vil blive synkroniseret tilbage til adressebogen."), ("Change Color", "Skift farve"), ("Primary Color", "Primær farve"), - ("HSV Color", "HSV farve"), + ("HSV Color", "HSV-farve"), ("Installation Successful!", "Installation fuldført!"), ("Installation failed!", "Installation mislykkedes!"), ("Reverse mouse wheel", "Invertér musehjul"), ("{} sessions", "{} sessioner"), ("scam_title", "ADVARSEL: Du kan blive SVINDLET!"), - ("scam_text1", "Hvis du taler telefon med en person du IKKE kender, og IKKE stoler på, som har bedt dig om at bruge RustDesk til at forbinde til din PC, stop med det samme, og læg på omgående."), - ("scam_text2", "Det er højest sandsynligvis en svinder som forsøger at stjæle dine penge eller andre personlige oplysninger."), + ("scam_text1", "Hvis du taler telefon med en person du IKKE kender, og IKKE stoler på, som har bedt dig om at bruge RustDesk til at forbinde til din PC, så stop med det samme, og læg på omgående."), + ("scam_text2", "Det er højst sandsynligvis en svinder som forsøger at stjæle dine penge eller andre personlige oplysninger."), ("Don't show again", "Vis ikke igen"), ("I Agree", "Jeg accepterer"), ("Decline", "Afvis"), @@ -524,7 +524,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Check for software update on startup", "Søg efter opdateringer ved opstart"), ("upgrade_rustdesk_server_pro_to_{}_tip", "Opgradér venligst RustDesk Server Pro til version {} eller nyere!"), ("pull_group_failed_tip", "Genindlæsning af gruppe mislykkedes"), - ("Filter by intersection", "Filtrér efter intersection"), + ("Filter by intersection", "Filtrér efter fællesmængde"), ("Remove wallpaper during incoming sessions", "Skjul baggrundsskærm ved indgående forbindelser"), ("Test", "Test"), ("display_is_plugged_out_msg", "Skærmen er slukket, skift til den første skærm."), @@ -532,7 +532,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Open in new window", "Åbn i et nyt vindue"), ("Show displays as individual windows", "Vis skærme som selvstændige vinduer"), ("Use all my displays for the remote session", "Brug alle mine skærme til fjernforbindelsen"), - ("selinux_tip", "SELinux er aktiveret på din enhed, som kan forhindre RustDesk i at køre normalt."), + ("selinux_tip", "SELinux er aktiveret på din enhed, hvilket kan forhindre RustDesk i at køre normalt."), ("Change view", "Skift visning"), ("Big tiles", "Store fliser"), ("Small tiles", "Små fliser"), @@ -541,25 +541,25 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Plug out all", "Frakobl alt"), ("True color (4:4:4)", "True color (4:4:4)"), ("Enable blocking user input", "Aktivér blokering af brugerstyring"), - ("id_input_tip", "Du kan indtaste ét ID, en direkte IP adresse, eller et domæne med en port (:).\nHvis du ønsker at forbinde til en enhed på en anden server, tilføj da server adressen (@?key=), fx,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nHvis du ønsker adgang til en enhed på en offentlig server, indtast venligst \"@offentlig server\", nøglen er ikke nødvendig for offentlige servere.\n\nHvis du gerne vil tvinge brugen af en relay-forbindelse på den første forbindelse, tilføj \"/r\" efter ID'et, fx, \"9123456234/r\"."), + ("id_input_tip", "Du kan indtaste ét ID, en direkte IP-adresse, eller et domæne med en port (:).\nHvis du ønsker at forbinde til en enhed på en anden server, tilføj da serveradressen (@?key=), fx,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nHvis du ønsker adgang til en enhed på en offentlig server, indtast venligst \"@offentlig server\"; nøglen er ikke nødvendig for offentlige servere.\n\nHvis du vil gennemtvinge brug af en relay-forbindelse på den første forbindelse, så tilføj \"/r\" efter ID'et, fx, \"9123456234/r\"."), ("privacy_mode_impl_mag_tip", "Tilstand 1"), ("privacy_mode_impl_virtual_display_tip", "Tilstand 2"), ("Enter privacy mode", "Start privatlivstilstand"), ("Exit privacy mode", "Afslut privatlivstilstand"), - ("idd_not_support_under_win10_2004_tip", "Indirekte grafik drivere er ikke understøttet. Windows 10 version 2004 eller nyere er påkrævet."), + ("idd_not_support_under_win10_2004_tip", "Indirekte grafikdrivere er ikke understøttet. Windows 10 version 2004 eller nyere er påkrævet."), ("input_source_1_tip", "Input kilde 1"), ("input_source_2_tip", "Input kilde 2"), - ("Swap control-command key", "Byt rundt på Control & Command tasterne"), + ("Swap control-command key", "Byt rundt på Ctrl- og Command-tasterne"), ("swap-left-right-mouse", "Byt rundt på venstre og højre musetaster"), - ("2FA code", "To-faktor kode"), + ("2FA code", "To-faktorkode"), ("More", "Mere"), - ("enable-2fa-title", "Tænd for to-faktor godkendelse"), - ("enable-2fa-desc", "Åbn din godkendelsesapp nu. Du kan bruge en godkendelsesapp så som Authy, Microsoft eller Google Authenticator på din telefon eller din PC.\n\nScan QR koden med din app og indtast koden som din app fremviser, for at aktivere for to-faktor godkendelse."), - ("wrong-2fa-code", "Kan ikke verificere koden. Forsikr at koden og tidsindstillingerne på enheden er korrekte"), - ("enter-2fa-title", "To-faktor godkendelse"), - ("Email verification code must be 6 characters.", "E-mail bekræftelseskode skal være mindst 6 tegn"), - ("2FA code must be 6 digits.", "To-faktor kode skal være mindst 6 cifre"), - ("Multiple Windows sessions found", "Flere Windows sessioner fundet"), + ("enable-2fa-title", "Tænd for to-faktorgodkendelse"), + ("enable-2fa-desc", "Åbn din godkendelsesapp nu. Du kan bruge en godkendelsesapp såsom Authy, Microsoft eller Google Authenticator på din telefon eller din PC.\n\nScan QR-koden med din app og indtast koden som din app fremviser for at aktivere to-faktorgodkendelse."), + ("wrong-2fa-code", "Kan ikke verificere koden. Sikr dig at koden og tidsindstillingerne på enheden er korrekte"), + ("enter-2fa-title", "To-faktorgodkendelse"), + ("Email verification code must be 6 characters.", "E-mail-bekræftelseskoden skal være på 6 tegn"), + ("2FA code must be 6 digits.", "To-faktorkoden skal være på 6 cifre"), + ("Multiple Windows sessions found", "Flere Windows-sessioner fundet"), ("Please select the session you want to connect to", "Vælg venligst sessionen du ønsker at forbinde til"), ("powered_by_me", "Drives af RustDesk"), ("outgoing_only_desk_tip", "Dette er en brugertilpasset udgave.\nDu kan forbinde til andre enheder, men andre enheder kan ikke forbinde til din enhed."), @@ -571,44 +571,44 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Set shared password", "Sæt delt adgangskode"), ("Exist in", "Findes i"), ("Read-only", "Skrivebeskyttet"), - ("Read/Write", "Læse/Skrive"), + ("Read/Write", "Læse/skrive"), ("Full Control", "Fuld kontrol"), - ("share_warning_tip", "Felterne for oven er delt og synlige for andre."), + ("share_warning_tip", "Felterne foroven er delt og synlige for andre."), ("Everyone", "Alle"), - ("ab_web_console_tip", "Mere på web konsollen"), - ("allow-only-conn-window-open-tip", "Tillad kun fjernforbindelser hvis RustDesk vinduet er synligt"), - ("no_need_privacy_mode_no_physical_displays_tip", "Ingen fysiske skærme, ingen nødvendighed for at bruge privatlivstilstanden."), + ("ab_web_console_tip", "Mere på webkonsollen"), + ("allow-only-conn-window-open-tip", "Tillad kun fjernforbindelser hvis RustDesk-vinduet er synligt"), + ("no_need_privacy_mode_no_physical_displays_tip", "Ingen fysiske skærme, ikke nødvendigt at bruge privatlivstilstanden."), ("Follow remote cursor", "Følg musemarkør på fjernforbindelse"), - ("Follow remote window focus", "Følg vinduefokus på fjernforbindelse"), + ("Follow remote window focus", "Følg vinduesfokus på fjernforbindelse"), ("default_proxy_tip", "Protokollen og porten som anvendes som standard er Socks5 og 1080"), - ("no_audio_input_device_tip", "Ingen lydinput enhed fundet"), + ("no_audio_input_device_tip", "Ingen lydinputenhed fundet"), ("Incoming", "Indgående"), ("Outgoing", "Udgående"), - ("Clear Wayland screen selection", "Ryd Wayland skærmvalg"), - ("clear_Wayland_screen_selection_tip", "Efter at fravælge den valgte skærm, kan du genvælge skærmen som skal deles."), - ("confirm_clear_Wayland_screen_selection_tip", "Er du sikker på at du vil fjerne Wayland skærmvalget?"), + ("Clear Wayland screen selection", "Ryd Wayland-skærmvalg"), + ("clear_Wayland_screen_selection_tip", "Efter du har fravalgt den valgte skærm, kan du vælge skærmen som skal deles."), + ("confirm_clear_Wayland_screen_selection_tip", "Er du sikker på at du vil fjerne Wayland-skærmvalget?"), ("android_new_voice_call_tip", "Du har modtaget en ny stemmeopkaldsforespørgsel. Hvis du accepterer, vil lyden skifte til stemmekommunikation."), ("texture_render_tip", "Brug tekstur-rendering for at gøre billedkvaliteten blødere. Du kan også prøve at deaktivere denne funktion, hvis du oplever problemer."), ("Use texture rendering", "Anvend tekstur-rendering"), ("Floating window", "Svævende vindue"), - ("floating_window_tip", "Det hjælper på at RustDesk baggrundstjenesten kører"), + ("floating_window_tip", "Det hjælper til at holde RustDesk-baggrundstjenesten kørende"), ("Keep screen on", "Hold skærmen tændt"), ("Never", "Aldrig"), - ("During controlled", "Imens under kontrol"), + ("During controlled", "Under fjernstyring"), ("During service is on", "Imens tjenesten kører"), ("Capture screen using DirectX", "Optag skærm med DirectX"), ("Back", "Tilbage"), ("Apps", "Apps"), ("Volume up", "Skru op for lyd"), ("Volume down", "Skru ned for lyd"), - ("Power", "Tænd/Sluk"), - ("Telegram bot", "Telegram bot"), - ("enable-bot-tip", "Hvis du aktiverer denne funktion, kan du modtage to-faktor godkendelseskoden fra din robot. Den kan også fungere som en notifikation for forbindelsesanmodninger."), + ("Power", "Tænd/sluk"), + ("Telegram bot", "Telegram-bot"), + ("enable-bot-tip", "Hvis du aktiverer denne funktion, kan du modtage to-faktorgodkendelseskoden fra din robot. Den kan også fungere som en notifikation for forbindelsesanmodninger."), ("enable-bot-desc", "1. Åbn en chat med @BotFather.\n2. Send kommandoen \"/newbot\". Du vil modtage en nøgle efter at have gennemført dette trin.\n3. Start en chat med din nyoprettede bot. Send en besked som begynder med skråstreg \"/\", som fx \"/hello\", for at aktivere den.\n"), - ("cancel-2fa-confirm-tip", "Er du sikker på at du vil afbryde to-faktor godkendelse?"), - ("cancel-bot-confirm-tip", "Er du sikker på at du vil afbryde Telegram robotten?"), + ("cancel-2fa-confirm-tip", "Er du sikker på at du vil afbryde to-faktorgodkendelse?"), + ("cancel-bot-confirm-tip", "Er du sikker på at du vil afbryde Telegram-robotten?"), ("About RustDesk", "Om RustDesk"), - ("Send clipboard keystrokes", "Send udklipsholder tastetryk"), + ("Send clipboard keystrokes", "Send udklipsholder-tastetryk"), ("network_error_tip", "Tjek venligst din internetforbindelse, og forsøg igen."), ("Unlock with PIN", "Lås op med PIN"), ("Requires at least {} characters", "Kræver mindst {} tegn"), @@ -618,7 +618,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Manage trusted devices", "Administrér troværdige enheder"), ("Platform", "Platform"), ("Days remaining", "Dage tilbage"), - ("enable-trusted-devices-tip", "Spring to-faktor godkendelse over på troværdige enheder"), + ("enable-trusted-devices-tip", "Spring to-faktorgodkendelse over på troværdige enheder"), ("Parent directory", "mappe"), ("Resume", "Fortsæt"), ("Invalid file name", "Ugyldigt filnavn"), @@ -682,7 +682,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Enable terminal", "Aktivér terminal"), ("New tab", "Ny fane"), ("Keep terminal sessions on disconnect", "Behold terminalsessioner ved afbrydelse"), - ("Terminal (Run as administrator)", "Terminal (Kør som administrator)"), + ("Terminal (Run as administrator)", "Terminal (kør som administrator)"), ("terminal-admin-login-tip", "Indtast venligst administratorbrugernavnet og adgangskoden på den kontrollerede side."), ("Failed to get user token.", "Kunne ikke hente brugertoken."), ("Incorrect username or password.", "Forkert brugernavn eller adgangskode."), @@ -700,7 +700,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Virtual mouse size", "Størrelse på virtuel mus"), ("Small", "Lille"), ("Large", "Stor"), - ("Show virtual joystick", "Vis virtuel joystick"), + ("Show virtual joystick", "Vis virtuelt joystick"), ("Edit note", "Redigér note"), ("Alias", "Alias"), ("ScrollEdge", "ScrollEdge"), @@ -708,7 +708,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("allow-insecure-tls-fallback-tip", "Som standard verificerer RustDesk servercertifikatet for protokoller, der bruger TLS.\nNår denne indstilling er aktiveret, vil RustDesk springe verificeringstrinnet over og fortsætte, hvis verificeringen mislykkes."), ("Disable UDP", "Deaktivér UDP"), ("disable-udp-tip", "Bestemmer, om der kun skal bruges TCP.\nNår denne indstilling er aktiveret, vil RustDesk ikke længere bruge UDP 21116; i stedet bruges TCP 21116."), - ("server-oss-not-support-tip", "BEMÆRK: RustDesk server OSS indeholder ikke denne funktion."), + ("server-oss-not-support-tip", "BEMÆRK: RustDesk Server OSS indeholder ikke denne funktion."), ("input note here", "indtast note her"), ("note-at-conn-end-tip", "Spørg om note ved afslutningen af forbindelsen"), ("Show terminal extra keys", "Vis ekstra terminaltaster"), From 7cc82c1575f77460168ce467147770b220294c10 Mon Sep 17 00:00:00 2001 From: Abdullah Kaleem Date: Tue, 25 Aug 2026 06:19:37 +0500 Subject: [PATCH 54/72] Add Urdu language support for UI strings (#15961) * Add Urdu language support for UI strings till 329 line Co-authored-by: Copilot * Add Urdu translations for additional UI strings * Add Urdu language support in lang.rs * Fix Urdu translations and remove unused keys in ur.rs --------- Co-authored-by: Copilot --- src/lang.rs | 3 + src/lang/ur.rs | 753 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 756 insertions(+) create mode 100644 src/lang/ur.rs diff --git a/src/lang.rs b/src/lang.rs index 077c6232a..8001b6e71 100644 --- a/src/lang.rs +++ b/src/lang.rs @@ -45,6 +45,7 @@ mod th; mod tr; mod tw; mod uk; +mod ur; mod vi; mod ta; mod ge; @@ -80,6 +81,7 @@ pub const LANGS: &[(&str, &str)] = &[ ("ko", "한국어"), ("kz", "Қазақ"), ("uk", "Українська"), + ("ur", "اردو"), ("fa", "فارسی"), ("ca", "Català"), ("el", "Ελληνικά"), @@ -208,6 +210,7 @@ pub fn translate_locale(name: String, locale: &str) -> String { "be" => be::T.deref(), "he" => he::T.deref(), "hr" => hr::T.deref(), + "ur" => ur::T.deref(), "sc" => sc::T.deref(), "ta" => ta::T.deref(), "ge" => ge::T.deref(), diff --git a/src/lang/ur.rs b/src/lang/ur.rs new file mode 100644 index 000000000..f40ec817c --- /dev/null +++ b/src/lang/ur.rs @@ -0,0 +1,753 @@ +lazy_static::lazy_static! { +pub static ref T: std::collections::HashMap<&'static str, &'static str> = + [ + ("Status", "حالت"), + ("Your Desktop", "آپ کا ڈیسک ٹاپ"), + ("desk_tip", ""), + ("Password", "پاس ورڈ"), + ("Ready", "تیار"), + ("Established", "قائم کیا گیا"), + ("connecting_status", "کنیکٹنگ_سٹیٹس"), + ("Enable service", "سروس کو فعال کریں"), + ("Start service", "سروس شروع کریں"), + ("Service is running", "سروس چل رہی ہے"), + ("Service is not running", "سروس نہیں چل رہی ہے"), + ("not_ready_status", ""), + ("Control Remote Desktop", "ریموٹ ڈیسک ٹاپ کو کنٹرول کریں"), + ("Transfer file", "فائل منتقل کریں"), + ("Connect", "کنیکٹ کریں"), + ("Recent sessions", "حالیہ سیشنز"), + ("Address book", "پتہ کتاب"), + ("Confirmation", "تصدیق"), + ("TCP tunneling", "TCP ٹنلینگ"), + ("Remove", "ہٹائیں"), + ("Refresh random password", "بے ترتیب پاس ورڈ ریفریش کریں"), + ("Set your own password", "اپنا پاس ورڈ خود سیٹ کریں"), + ("Enable keyboard/mouse", "ماوس/کی بورڈ کو فعال کریں"), + ("Enable clipboard", "کلپ بورڈ کو فعال کریں"), + ("Enable file transfer", "فائل ٹرانسفر کو فعال کریں"), + ("Enable TCP tunneling", "TCP ٹنلینگ کو فعال کریں"), + ("IP Whitelisting", "IP وائٹ لسٹنگ"), + ("ID/Relay Server", "ID/ریلے سرور"), + ("Import server config", "سرور کی تشکیل درآمد کریں"), + ("Export Server Config", "سرور کی تشکیل برآمد کریں"), + ("Import server configuration successfully", "سرور کی تشکیل کامیابی سے درآمد ہو گئی"), + ("Export server configuration successfully", "سرور کی تشکیل کامیابی سے برآمد ہو گئی"), + ("Invalid server configuration", "سرور کی تشکیل غلط ہے"), + ("Clipboard is empty", "کلپ بورڈ خالی ہے"), + ("Stop service", "سروس بند کریں"), + ("Change ID", "ٰID تبدیل کریں"), + ("Your new ID", "آپ کی نئی ID"), + ("length %min% to %max%", "لمبائی %min% سے %max%"), + ("starts with a letter", "حرف سے شروع ہوتا ہے"), + ("allowed characters", "اجازت یافتہ حروف"), + ("id_change_tip", ""), + ("Website", "ویب سائٹ"), + ("About", "کے بارے میں"), + ("Slogan_tip", "سلوگن_ٹپ"), + ("Privacy Statement", "رازداری کا بیان"), + ("License", "لائسنس"), + ("Mute", "خاموش"), + ("Build Date", "بنیاد کی تاریخ"), + ("Version", "ورژن"), + ("Home", "گھر"), + ("Audio Input", "آڈیو ان پٹ"), + ("Enhancements", "اضافہ"), + ("Hardware Codec", "ہارڈ ویئر کوڈیک"), + ("Adaptive bitrate", "ایڈاپٹیو بٹ ریٹ"), + ("ID Server", "ID سرور"), + ("Relay Server", "ریلے سرور"), + ("API Server", "اے پی آئی سرور"), + ("invalid_http", "غلط HTTP"), + ("Invalid IP", "غلط IP"), + ("Invalid format", "غلط فارمیٹ"), + ("server_not_support", "سرور کی حمایت نہیں ہے"), + ("Not available", "دستیاب نہیں"), + ("Too frequent", "بہت اکثر"), + ("Cancel", "منسوخ کریں"), + ("Skip", "چھوڑ دیں"), + ("Close", "بند کریں"), + ("Retry", "دوبارہ کوشش کریں"), + ("OK", "ٹھیک ہے"), + ("Password Required", "پاس ورڈ درکار ہے"), + ("Please enter your password", "اپنا پاس ورڈ درج کریں"), + ("Remember password", "پاس ورڈ یاد رکھیں"), + ("Wrong Password", "غلط پاس ورڈ"), + ("Do you want to enter again?", "کیا آپ دوبارہ اندراج کرنا چاہتے ہیں؟"), + ("Connection Error", "کنکشن کی خرابی"), + ("Error", "خرابی"), + ("Reset by the peer", "پیر کی طرف سے ری سیٹ"), + ("Connecting...", "کنیکٹ ہو رہا ہے..."), + ("Connection in progress. Please wait.", "کنکشن کیا جا رہا ہے۔ براہِ مہربانی انتظار کریں۔"), + ("Please try 1 minute later", "براہِ مہربانی 1 منٹ بعد کوشش کریں"), + ("Login Error", "لاگ ان کی خرابی"), + ("Successful", "کامیاب"), + ("Connected, waiting for image...", "کنیکٹ ہو گیا، تصویر کے لیے انتظار کر رہا ہے..."), + ("Name", "نام"), + ("Type", "ٹائپ"), + ("Modified", "تبدیل"), + ("Size", "حجم"), + ("Show Hidden Files", "خفیہ فائلیں دکھائیں"), + ("Receive", "وصول کریں"), + ("Send", "بھیجیں"), + ("Refresh File", "فائل ریفریش کریں"), + ("Local", "مقامی"), + ("Remote", "ریموٹ"), + ("Remote Computer", "ریموٹ کمپیوٹر"), + ("Local Computer", "مقامی کمپیوٹر"), + ("Confirm Delete", "حذف کی تصدیق کریں"), + ("Delete", "حذف کریں"), + ("Properties", "خصوصیات"), + ("Multi Select", "ملٹی سلیکٹ"), + ("Select All", "سب کو منتخب کریں"), + ("Unselect All", "سب کو غیر منتخب کریں"), + ("Empty Directory", "خالی ڈائرکٹری"), + ("Not an empty directory", "خالی ڈائرکٹری نہیں"), + ("Are you sure you want to delete this file?", "کیا آپ واقعی اس فائل کو حذف کرنا چاہتے ہیں؟"), + ("Are you sure you want to delete this empty directory?", "کیا آپ واقعی اس خالی ڈائرکٹری کو حذف کرنا چاہتے ہیں؟"), + ("Are you sure you want to delete the file of this directory?", "کیا آپ واقعی اس ڈائرکٹری کی فائل کو حذف کرنا چاہتے ہیں؟"), + ("Do this for all conflicts", "تمام تضادوں کے لئے یہ کرو"), + ("This is irreversible!", "ینہ واپس نہ لایا جا سکتا!"), + ("Deleting", "حذف ہو رہا ہے..."), + ("files", "فائلیں"), + ("Waiting", "انتظار کر رہا ہے"), + ("Finished", "ختم ہو گیا"), + ("Speed", "رفتار"), + ("Custom Image Quality", "کسٹم تصویر کی معیار"), + ("Privacy mode", "موڈ رازداری "), + ("Block user input", "یوزر ان پٹ کو بلاک کریں"), + ("Unblock user input", "یوزر ان پٹ کو غیر بلاک کریں"), + ("Adjust Window", "ونڈو کو سیدھا کریں"), + ("Original", "اصل"), + ("Shrink", "کم کریں"), + ("Stretch", "وسیع کریں"), + ("Scrollbar", "اسکرول بار"), + ("ScrollAuto", "آٹو اسکرول"), + ("Good image quality", "اچھی تصویر کی معیار"), + ("Balanced", "متوازن"), + ("Optimize reaction time", "ریکشن کے وقت کو بہتر بنائیں"), + ("Custom", "کسٹم"), + ("Show remote cursor", "ریموٹ کرسر دکھائیں"), + ("Show quality monitor", "معیار کا مانیٹر دکھائیں"), + ("Disable clipboard", "کلپ بورڈ کو غیر فعال کریں"), + ("Lock after session end", "سیشن ختم ہونے کے بعد لاک کریں"), + ("Insert Ctrl + Alt + Del", "Ctrl + Alt + Del داخل کریں"), + ("Insert Lock", "لاک داخل کریں"), + ("Refresh", "ریفریش کریں"), + ("ID", ""), + ("ID does not exist", "ID موجود نہیں ہے"), + ("Failed to connect to rendezvous server", "رینڈوز سرور سے کنکشن کرنے میں ناکام"), + ("Please try later", "براہِ مہربانی بعد میں کوشش کریں"), + ("Remote desktop is offline", "ریموٹ ڈیسکٹاپ آف لائن ہے"), + ("Key mismatch", "کلید ممچ نہیں"), + ("Timeout", "وقت کی ختم"), + ("Failed to connect to relay server", "ریلے سرور سے کنکشن کرنے میں ناکام"), + ("Failed to connect via rendezvous server", "رینڈوز سرور سے کنکشن کرنے میں ناکام"), + ("Failed to connect via relay server", "ریلے سرور سے کنکشن کرنے میں ناکام"), + ("Failed to make direct connection to remote desktop", "ریموٹ ڈیسکٹاپ سے مستقیم کنکشن قائم کرنے میں ناکام"), + ("Set Password", "پاس ورڈ مرتب کریں"), + ("OS Password", "OS پاس ورڈ"), + ("install_tip", "انسٹال کرنے کا مشورہ"), + ("Click to upgrade", "اپگریڈ کرنے کے لئے کلک کریں"), + ("Configure", "ترتیب دینا"), + ("config_acc", ""), + ("config_screen", ""), + ("Installing ...", "انسٹال ہو رہا ہے..."), + ("Install", "انسٹال کریں"), + ("Installation", "انسٹالیشن"), + ("Installation Path", "انسٹالیشن کا راستہ"), + ("Create start menu shortcuts", "اسٹارٹ مینو شارٹ کٹس بنائیں"), + ("Create desktop icon", "ڈیسکٹاپ آئیکن بنائیں"), + ("agreement_tip", ""), + ("Accept and Install", "قبول کریں اور انسٹال کریں"), + ("End-user license agreement", "اختتامی صارف کے لائسنس کا معاہدہ"), + ("Generating ...", "بنا رہے ہیں..."), + ("Your installation is lower version.", "آپ کی تنصیب کم ورژن ہے۔"), + ("Please install the latest version.", "براہِ مہربانی تازہ ترین ورژن انسٹال کریں۔"), + ("not_close_tcp_tip", ""), + ("Listening ...", "سن رہا ہے..."), + ("Remote Host", "ریموٹ میزبان"), + ("Remote Port", "ریموٹ پورٹ"), + ("Action", "عمل"), + ("Add", "شامل کریں"), + ("Local Port", "مقامی پورٹ"), + ("Local Address", "مقامی ایڈریس"), + ("Change Local Port", "مقامی پورٹ تبدیل کریں"), + ("setup_server_tip", "سرور کی ترتیب کا مشورہ"), + ("Too short, at least 6 characters.", "بہت چھوٹا، کم از کم 6 حروف۔"), + ("The confirmation is not identical.", "تصدیق ایک جیسی نہیں ہے۔"), + ("Permissions", "اجازتیں"), + ("Accept", "قبول کریں"), + ("Dismiss", "مسترد کریں"), + ("Disconnect", "منقطع کریں"), + ("Enable file copy and paste", "فائل کاپی اور پیسٹ فعال کریں"), + ("Connected", "منسلک ہے"), + ("Direct and encrypted connection", "براہِ راست اور خفیہ کنکشن"), + ("Relayed and encrypted connection", "آگے بڑھا ہوا اور خفیہ کنکشن"), + ("Direct and unencrypted connection", "براہِ راست اور غیر خفیہ کنکشن"), + ("Relayed and unencrypted connection", "آگے بڑھا ہوا اور غیر خفیہ کنکشن"), + ("Enter Remote ID", "ریموٹ آئی ڈی درج کریں"), + ("Enter your password", "اپنا پاس ورڈ درج کریں"), + ("Logging in...", "لاگ ان ہو رہا ہے..."), + ("Login", "لاگ ان کریں"), + ("Enable RDP session sharing", "RDP سیشن شیئرنگ کو فعال کریں"), + ("Auto Login", "خودکار لاگ ان"), + ("Enable direct IP access", "براہِ راست IP رسائی کو فعال کریں"), + ("Rename", "نام تبدیل کریں"), + ("Space", "جگہ"), + ("Create desktop shortcut", "ڈیسک ٹاپ شارٹ کٹ بنائیں"), + ("Change Path", "راستہ تبدیل کریں"), + ("Create Folder", "فولڈر بنائیں"), + ("Please enter the folder name", "فولڈر کا نام درج کریں"), + ("Fix it", "ٹھیک کریں"), + ("Warning", "انتباہ"), + ("Login screen using Wayland is not supported", "Wayland کا استعمال کرتے ہوئے لاگ ان اسکرین کی حمایت نہیں کی جاتی ہے"), + ("Reboot required", "دوبارہ شروع کرنے کی ضرورت ہے"), + ("Unsupported display server", "غیر معاون ڈسپلے سرور"), + ("x11 expected", "x11 کی توقع ہے"), + ("Port", "پورٹ"), + ("Settings", "ترتیبات"), + ("Username", "یوزر نیم"), + ("Invalid port", "غلط پورٹ"), + ("Closed manually by the peer", "پیر کی طرف سے دستی طور پر بند"), + ("Enable remote configuration modification", "ریموٹ کنفیگریشن ترمیم کو فعال کریں"), + ("Run without install", "انسٹال کے بغیر چلائیں"), + ("Connect via relay", "ریلے کے ذریعے کنیکٹ کریں"), + ("Always connect via relay", "ہمیشہ ریلے کے ذریعے کنیکٹ کریں"), + ("whitelist_tip", ""), + ("Login", "لاگ ان"), + ("Verify", "تصدیق کریں"), + ("Remember me", "یاد رکھیں"), + ("Trust this device", "اس ڈیوائس پر اعتماد کریں"), + ("Verification code", "تصدیق کوڈ"), + ("verification_tip", "تصدیق کا مشورہ"), + ("Logout", "لاگ آؤٹ"), + ("Tags", "ٹیگز"), + ("Search ID", "ID تلاش کریں"), + ("whitelist_sep", ""), + ("Add ID", "ID شامل کریں"), + ("Add Tag", "ٹیگ شامل کریں"), + ("Unselect all tags", "تمام ٹیگز کو غیر منتخب کریں"), + ("Network error", "نیٹ ورک کی خرابی"), + ("Username missed", "یوزر نیم چھوٹ گیا"), + ("Password missed", "پاس ورڈ چھوٹ گیا"), + ("Wrong credentials", "غلط اسناد"), + ("The verification code is incorrect or has expired", "تصدیق کوڈ غلط ہے یا ختم ہو چکا ہے"), + ("Edit Tag", "ٹیگ ایڈٹ کریں"), + ("Forget Password", "پاس ورڈ بھول گئے"), + ("Favorites", "پسندیدہ"), + ("Add to Favorites", "پسندیدہ میں شامل کریں"), + ("Remove from Favorites", "پسندیدہ سے ہٹائیں"), + ("Empty", "خالی"), + ("Invalid folder name", "فولڈر کا نام غلط ہے"), + ("Socks5 Proxy", "پروکسی ساکس5"), + ("Socks5/Http(s) Proxy", "ساکس5/Http(s) پروکسی"), + ("Discovered", "دریافت شدہ"), + ("install_daemon_tip", ""), + ("Remote ID", "ریموٹ ID"), + ("Paste", "چسپاں کریں"), + ("Paste here?", "یہاں چسپاں کریں؟"), + ("Are you sure to close the connection?", "کیا آپ واقعی کنکشن بند کرنا چاہتے ہیں؟"), + ("Download new version", "نیا ورژن ڈاؤن لوڈ کریں"), + ("Touch mode", "تچ موڈ"), + ("Mouse mode", "ماؤس موڈ"), + ("One-Finger Tap", "ایک انگلی سے ٹیپ"), + ("Left Mouse", "بائیں ماؤس"), + ("One-Long Tap", "ایک لمبا ٹیپ"), + ("Two-Finger Tap", "دو انگلیوں سے ٹیپ"), + ("Right Mouse", "دائیں ماؤس"), + ("One-Finger Move", "ایک انگلی سے حرکت"), + ("Double Tap & Move", "دو بار ٹیپ اور حرکت"), + ("Mouse Drag", "ماؤس گھسیٹنا"), + ("Three-Finger vertically", "تین انگلیوں سے عمودی"), + ("Mouse Wheel", "ماؤس ویل"), + ("Two-Finger Move", "دو انگلیوں سے حرکت"), + ("Canvas Move", "کینوس حرکت"), + ("Pinch to Zoom", "زوم کرنے کے لیے چوٹکی"), + ("Canvas Zoom", "کینوس زوم"), + ("Reset canvas", "کینوس ری سیٹ کریں"), + ("No permission of file transfer", "فائل ٹرانسفر کی اجازت نہیں ہے"), + ("Note", "نوٹ"), + ("Connection", "رابطہ"), + ("Share screen", "سکرین شیئر کریں"), + ("Chat", "بات چیت"), + ("Total", "کل"), + ("items", "اشیاء"), + ("Selected", "منتخب شدہ"), + ("Screen Capture", "سکرین قابض"), + ("Input Control", "درآمد کنٹرول"), + ("Audio Capture", "آڈیو قابض"), + ("Do you accept?", "کیا آپ قبول کرتے ہیں؟"), + ("Open System Setting", "سسٹم کی ترتیبات کھولیں"), + ("How to get Android input permission?", "Android کی درآمد کی اجازت کیسے حاصل کریں؟"), + ("android_input_permission_tip1", ""), + ("android_input_permission_tip2", ""), + ("android_new_connection_tip", ""), + ("android_service_will_start_tip", ""), + ("android_stop_service_tip", ""), + ("android_version_audio_tip", ""), + ("android_start_service_tip", ""), + ("android_permission_may_not_change_tip", ""), + ("Account", "کھاتا"), + ("Overwrite", "اوور رائٹ کریں"), + ("This file exists, skip or overwrite this file?", "یہ فائل موجود ہے، اس فائل کو چھوڑیں یا اوور رائٹ کریں؟"), + ("Quit", "بند کریں"), + ("Help", "مدد"), + ("Failed", "ناکام"), + ("Succeeded", "کامیاب ہو گیا"), + ("Someone turns on privacy mode, exit", "کوئی پرائیویسی موڈ آن کرتا ہے، باہر نکلیں"), + ("Unsupported", "غیر معاون"), + ("Peer denied", "ہم منسب نے انکار کر دیا"), + ("Please install plugins", "براہِ مہربانی پلگ ان انسٹال کریں"), + ("Peer exit", "ہم منسب باہر نکل گیا"), + ("Failed to turn off", "بند کرنے میں ناکام"), + ("Turned off", "بند کر دیا"), + ("Language", "زبان"), + ("Keep RustDesk background service", "RustDesk پس منظر کی خدمت کو برقرار رکھیں"), + ("Ignore Battery Optimizations", "بیٹری کی اصلاحات کو نظر انداز کریں"), + ("android_open_battery_optimizations_tip", ""), + ("Start on boot", "شروع کرنے پر شروع کریں"), + ("Start the screen sharing service on boot, requires special permissions", "بوٹ پر سکرین شیئرنگ سروس شروع کریں، خاص اجازتوں کی ضرورت ہے"), + ("Connection not allowed", "جڑنے کی اجازت نہیں ہے"), + ("Legacy mode", "میراث موڈ"), + ("Map mode", "میپ موڈ"), + ("Translate mode", "ترجمہ موڈ"), + ("Use permanent password", "مستقل پاس ورڈ استعمال کریں"), + ("Use both passwords", "دونوں پاس ورڈ استعمال کریں"), + ("Set permanent password", "مستقل پاس ورڈ مرتب کریں"), + ("Enable remote restart", "ریموٹ ری اسٹارٹ کو فعال کریں"), + ("Restart remote device", "ریموٹ ڈیوائس کو ری اسٹارٹ کریں"), + ("Are you sure you want to restart", "کیا آپ واقعی ری اسٹارٹ کرنا چاہتے ہیں؟"), + ("Restarting remote device", "ریموٹ ڈیوائس ری اسٹارٹ ہو رہی ہے"), + ("remote_restarting_tip", ""), + ("Copied", "نقل ہو گیا"), + ("Exit Fullscreen", "مکمل سکرین سے باہر نکلیں"), + ("Fullscreen", "مکمل سکرین"), + ("Mobile Actions", "موبائل کے عمل"), + ("Select Monitor", "مانیٹر منتخب کریں"), + ("Control Actions", "عمل کو قابو کریں"), + ("Display Settings", "ڈسپلے کی ترتیبات"), + ("Ratio", "تناسب"), + ("Image Quality", "تصویر کا معیار"), + ("Scroll Style", "سکرول اسٹائل"), + ("Show Toolbar", "ٹول بار دکھائیں"), + ("Hide Toolbar", "ٹول بار چھپائیں"), + ("Direct Connection", "مستقیم کنکشن"), + ("Relay Connection", "ریلے کنکشن"), + ("Secure Connection", "محفوظ کنکشن"), + ("Insecure Connection", "غیر محفوظ کنکشن"), + ("Scale original", "اصل پیمانہ"), + ("Scale adaptive", "اضافی پیمانہ"), + ("General", "جنرل"), + ("Security", "سیکورٹی"), + ("Theme", "تھیم"), + ("Dark Theme", "ڈارک تھیم"), + ("Light Theme", "لائٹ تھیم"), + ("Dark", "ڈارک"), + ("Light", "لائٹ"), + ("Follow System", "سسٹم کو اپناؤ"), + ("Enable hardware codec", "ہارڈ ویئر کوڈیک کو فعال کریں"), + ("Unlock Security Settings", "سیکورٹی ترتیبات کو اندراج کریں"), + ("Enable audio", "آڈیو کو فعال کریں"), + ("Unlock Network Settings", "نیٹ ورک ترتیبات کو اندراج کریں"), + ("Server", "سرور"), + ("Direct IP Access", "مستقیم IP رسائی"), + ("Proxy", "پراکسی"), + ("Apply", "لاگو کریں"), + ("Disconnect all devices?", "تمام ڈیوائسز سے رابطہ منقطع کریں؟"), + ("Clear", "صاف کریں"), + ("Audio Input Device", "آڈیو ان پٹ ڈیوائس"), + ("Use IP Whitelisting", "IP وہٹ لسٹنگ استعمال کریں"), + ("Network", "نیٹ ورک"), + ("Pin Toolbar", "ٹول بار پن کریں"), + ("Unpin Toolbar", "ٹول بار ان پن کریں"), + ("Recording", "ریکارڈنگ"), + ("Directory", "ڈائرکٹری"), + ("Automatically record incoming sessions", "آئندہ سیشنز کو خودکار طور پر ریکارڈ کریں"), + ("Automatically record outgoing sessions", "بہرحال سیشنز کو خودکار طور پر ریکارڈ کریں"), + ("Change", "تبدیل کریں"), + ("Start session recording", "سیشن ریکارڈنگ شروع کریں"), + ("Stop session recording", "سیشن ریکارڈنگ روک دیں"), + ("Enable recording session", "ریکارڈنگ سیشن کو فعال کریں"), + ("Enable LAN discovery", "LAN کی دریافت کو فعال کریں"), + ("Deny LAN discovery", "LAN کی دریافت کو رد کریں"), + ("Write a message", "ایک پیغام لکھیں"), + ("Prompt", "پرامپٹ"), + ("Please wait for confirmation of UAC...", "UAC کی تصدیق کے لئے انتظار کریں..."), + ("elevated_foreground_window_tip", "الیویٹڈ_فارگراؤنڈ_ونڈو_ٹپ"), + ("Disconnected", "منقطع ہو گیا"), + ("Other", "دوسرا"), + ("Confirm before closing multiple tabs", "زیادہ ٹیبز بند کرنے سے پہلے تصدیق کریں"), + ("Keyboard Settings", "کیبورڈ ترتیبات"), + ("Full Access", "مکمل رسائی"), + ("Screen Share", "سکرین شئیر"), + ("ubuntu-21-04-required", "ubuntu-21-04 کی ضرورت"), + ("wayland-requires-higher-linux-version", "wayland کو اعلی لینکس ورژن کی ضرورت ہے"), + ("xdp-portal-unavailable", "xdp پورٹل دستیاب نہیں ہے"), + ("JumpLink", "جمپ لنک"), + ("Please Select the screen to be shared(Operate on the peer side).", "شیئر کرنے کے لیے سکرین منتخب کریں (ہم منسب کی طرف سے کام کریں)۔"), + ("Show RustDesk", "RustDesk دکھائیں"), + ("This PC", "یہ PC"), + ("or", "یا"), + ("Elevate", "علیٰ کریں"), + ("Zoom cursor", "کورسرو زوم کریں"), + ("Accept sessions via password", "پاس ورڈ کے ذریعے سیشن قبول کریں"), + ("Accept sessions via click", "کلک کے ذریعے سیشن قبول کریں"), + ("Accept sessions via both", "دونوں کے ذریعے سیشن قبول کریں"), + ("Please wait for the remote side to accept your session request...", "رضائی کے لئے انتظار کریں..."), + ("One-time Password", "ایک بارہ پاس ورڈ"), + ("Use one-time password", "ایک بارہ پاس ورڈ استعمال کریں"), + ("One-time password length", "ایک بارہ پاس ورڈ کی لمبائی"), + ("Request access to your device", "اپنے آلہ تک رسائی کا درخواست دیں"), + ("Hide connection management window", "رابطہ مینجمنٹ ونڈو چھپائیں"), + ("hide_cm_tip", "hide_cm_tip"), + ("wayland_experiment_tip", "wayland_experiment_tip"), + ("Right click to select tabs", "ٹیبز منتخب کرنے کے لیے دائیں کلک کریں"), + ("Skipped", "چھوڑا گیا"), + ("Add to address book", "پتہ کتاب میں شامل کریں"), + ("Group", "گروپ"), + ("Search", "تلاش"), + ("Closed manually by web console", "ویب کنسول کے ذریعے دستی طور پر بند کیا گیا"), + ("Local keyboard type", "مقامی کیبورڈ کا قسم"), + ("Select local keyboard type", "مقامی کیبورڈ کا قسم منتخب کریں"), + ("software_render_tip", ""), + ("Always use software rendering", "ہم sempre سافٹ ویر رینڈرنگ استعمال کریں"), + ("config_input", "config_input"), + ("config_microphone", ""), + ("request_elevation_tip", ""), + ("Wait", "انتظار کریں"), + ("Elevation Error", "علیٰ کرنے کی خرابی"), + ("Ask the remote user for authentication", "ریموٹ صارف سے تصدیق کے لیے پوچھیں"), + ("Choose this if the remote account is administrator", "ریموٹ اکاؤنٹ ایڈمنسٹریٹر ہو تو یہ منتخب کریں"), + ("Transmit the username and password of administrator", "ایڈمنسٹریٹر کا صارف نام اور پاس ورڈ پروگرام کے ذریعے بھیجیں"), + ("still_click_uac_tip", ""), + ("Request Elevation", "علیٰ کرنے کا درخواست دیں"), + ("wait_accept_uac_tip", ""), + ("Elevate successfully", "علیٰ کامیابی سے ہو گئے"), + ("uppercase", "بڑے حروف"), + ("lowercase", "چھوٹے حروف"), + ("digit", "عدد"), + ("special character", "خاص حرف"), + ("length>=8", "لمبائی>=8"), + ("Weak", "ضعیف"), + ("Medium", "درمیان"), + ("Strong", "مضبوط"), + ("Switch Sides", "پلٹنے کے سائڈس"), + ("Please confirm if you want to share your desktop?", "براہ کرم تصدیق کریں اگر آپ اپنے ڈیسک ٹاپ کو شئیر کرنا چاہتے ہیں؟"), + ("Display", "ڈسپلے"), + ("Default View Style", "ڈیفالٹ دیکھنے کا طریقہ"), + ("Default Scroll Style", "ڈیفالٹ سکرول کا طریقہ"), + ("Default Image Quality", "ڈیفالٹ تصویر کی معیار"), + ("Default Codec", "ڈیفالٹ کوڈک"), + ("Bitrate", "بٹ ریٹ"), + ("FPS", ""), + ("Auto", "خودکار"), + ("Other Default Options", "دوسروں ڈیفالٹ اختیارات"), + ("Voice call", "صوتی کال"), + ("Text chat", "متن چیٹ"), + ("Stop voice call", "صوتی کال کو روکیں"), + ("relay_hint_tip", "relay_hint_tip"), + ("Reconnect", "دوبارہ کنکٹ کریں"), + ("Codec", "کوڈک"), + ("Resolution", "ریزولیشن"), + ("No transfers in progress", "کوئی منتقلی جاری نہیں"), + ("Set one-time password length", "ایک بار کے لیے پاس ورڈ کی لمبائی سیٹ کریں"), + ("RDP Settings", "RDP سیٹنگز"), + ("Sort by", "ترتیر کے لحاظ سے"), + ("New Connection", "نئی کنکشن"), + ("Restore", "بحال کریں"), + ("Minimize", "کم کریں"), + ("Maximize", "زیادہ کریں"), + ("Your Device", "آپ کا آلہ"), + ("empty_recent_tip", "خالی حالیہ ٹپ"), + ("empty_favorite_tip", "خالی پسندیدہ ٹپ"), + ("empty_lan_tip", "خالی LAN ٹپ"), + ("empty_address_book_tip", "خالی پتہ کتاب ٹپ"), + ("Empty Username", "خالی صارف نام"), + ("Empty Password", "خالی پاس ورڈ"), + ("Me", "میں"), + ("identical_file_tip", ""), + ("show_monitors_tip", ""), + ("View Mode", "دیکھنے کا طریقہ"), + ("login_linux_tip", "login_linux_tip"), + ("verify_rustdesk_password_tip", ""), + ("remember_account_tip", ""), + ("os_account_desk_tip", ""), + ("OS Account", "OS اکاؤنٹ"), + ("another_user_login_title_tip", ""), + ("another_user_login_text_tip", ""), + ("xorg_not_found_title_tip", ""), + ("xorg_not_found_text_tip", ""), + ("no_desktop_title_tip", ""), + ("no_desktop_text_tip", ""), + ("No need to elevate", "اپنے کو ہیں نہیں"), + ("System Sound", "سسٹم سائونڈ"), + ("Default", "ڈیفالٹ"), + ("New RDP", "نیا RDP"), + ("Fingerprint", "فنگر پرنٹ"), + ("Copy Fingerprint", "فنگر پرنٹ کاپی کریں"), + ("no fingerprints", "کوئی فنگر پرنٹ نہیں"), + ("Select a peer", "ایک پیر منتخب کریں"), + ("Select peers", "پیرز منتخب کریں"), + ("Plugins", "پلگ انز"), + ("Uninstall", "ان انسٹال کریں"), + ("Update", "اپڈیٹ کریں"), + ("Enable", "فعال کریں"), + ("Disable", "غیر فعال کریں"), + ("Options", "اختیارات"), + ("resolution_original_tip", ""), + ("resolution_fit_local_tip", ""), + ("resolution_custom_tip", ""), + ("Collapse toolbar", "ٹول بار کو سکڑیں"), + ("Accept and Elevate", "قبول کریں اور علیٰ کریں"), + ("accept_and_elevate_btn_tooltip", ""), + ("clipboard_wait_response_timeout_tip", ""), + ("Incoming connection", "آنے والا کنکشن"), + ("Outgoing connection", "جانے والا کنکشن"), + ("Exit", "خارج ہوں"), + ("Open", "کھولیں"), + ("logout_tip", ""), + ("Service", "سروس"), + ("Start", "شروع کریں"), + ("Stop", "روک دیں"), + ("exceed_max_devices", ""), + ("Sync with recent sessions", "پچھلے سیشنز کے ساتھ ہم آہنگ کریں"), + ("Sort tags", "ٹیگز کو ترتیب دیں"), + ("Open connection in new tab", "کنکشن کو نئے ٹیب میں کھولیں"), + ("Move tab to new window", "ٹیب کو نئی ونڈو میں منتقل کریں"), + ("Can not be empty", "خالی نہیں ہو سکتا"), + ("Already exists", "پہلے سے موجود ہے"), + ("Change Password", "پاسورڈ تبدیل کریں"), + ("Refresh Password", "پاسورڈ ریفریش کریں"), + ("ID", ""), + ("Grid View", "گوڈ ویو"), + ("List View", "لسٹ ویو"), + ("Select", "منتخب کریں"), + ("Toggle Tags", "ٹیگز ٹوگل کریں"), + ("pull_ab_failed_tip", ""), + ("push_ab_failed_tip", ""), + ("synced_peer_readded_tip", ""), + ("Change Color", "رنگ تبدیل کریں"), + ("Primary Color", "پرائمری رنگ"), + ("HSV Color", "HSV رنگ"), + ("Installation Successful!", "انسٹالیشن کامیاب ہو گئی"), + ("Installation failed!", "انسٹالیشن ناکام ہو گئی"), + ("Reverse mouse wheel", "ریورس ماؤس وھیل"), + ("{} sessions", "{} سیشنز"), + ("scam_title", "سکم ٹائٹل"), + ("scam_text1", "سکم ٹیکسٹ 1"), + ("scam_text2", "سکم ٹیکسٹ 2"), + ("Don't show again", "دوبارہ نہ دکھائیں"), + ("I Agree", "میں قبول کرتا ہوں"), + ("Decline", "ناکام کریں"), + ("Timeout in minutes", "منٹوں میں ٹائیم آؤٹ"), + ("auto_disconnect_option_tip", ""), + ("Connection failed due to inactivity", "انفعال کی وजہ سے کنکشن ناکام ہو گیا"), + ("Check for software update on startup", "سٹارٹ اپ پر سافٹ ویر اپڈیٹ کے لیے چیک کریں"), + ("upgrade_rustdesk_server_pro_to_{}_tip", ""), + ("pull_group_failed_tip", ""), + ("Filter by intersection", "فلٹر بائی انسٹریکشن"), + ("Remove wallpaper during incoming sessions", "ان کلینگ سیشنز کے دوران والپیپر کو ہٹائیں"), + ("Test", "ٹیسٹ"), + ("display_is_plugged_out_msg", "ڈسپلے پلگڈ آؤٹ میسج"), + ("No displays", "کوئی ڈسپلے نہیں"), + ("Open in new window", "نئی ونڈو میں کھولیں"), + ("Show displays as individual windows", "ڈسپلے کو افراد کے طور پر دکھائیں"), + ("Use all my displays for the remote session", "ریموٹ سیشن کے لیے میرے تمام ڈسپلے استعمال کریں"), + ("selinux_tip", ""), + ("Change view", "ویو تبدیل کریں"), + ("Big tiles", "بڑے ٹائل"), + ("Small tiles", "چھوٹے ٹائل"), + ("List", "لسٹ"), + ("Virtual display", "ویچول دسپلے"), + ("Plug out all", "تمام پلگ آؤٹ کریں"), + ("True color (4:4:4)", "اصل رنگ (4:4:4)"), + ("Enable blocking user input", "صارف ان پٹ کو روکنے کی اجازت دیں"), + ("Enable blocking user input", "کاربر ان پٹ کو روکنے کی اجازت دیں"), + ("id_input_tip", ""), + ("privacy_mode_impl_mag_tip", ""), + ("privacy_mode_impl_virtual_display_tip", ""), + ("Enter privacy mode", "خفیہ موڈ میں داخل ہوں"), + ("Exit privacy mode", "خفیہ موڈ سے باہر نکلیں"), + ("idd_not_support_under_win10_2004_tip", ""), + ("input_source_1_tip", ""), + ("input_source_2_tip", ""), + ("Swap control-command key", "control-command کلید کو سوپ کریں"), + ("swap-left-right-mouse", "بائی-دائی ماؤس کو سوپ کریں"), + ("2FA code", "2FA کوڈ"), + ("More", "مزید"), + ("enable-2fa-title", "2FA کو فعال کریں"), + ("enable-2fa-desc", "2FA کم سے زیادہ ترتیب دینے کے لیے فعال کریں"), + ("wrong-2fa-code", "2FA کوڈ غلط ہے"), + ("enter-2fa-title", "2FA کوڈ درج کریں"), + ("Email verification code must be 6 characters.", "ای میل توثیق کوڈ 6 حروف کا ہونا چاہیے."), + ("2FA code must be 6 digits.", "2FA کوڈ 6 اعداد کا ہونا چاہیے."), + ("Multiple Windows sessions found", "متعدد ونڈوز سیشن ملے"), + ("Please select the session you want to connect to", "براہ کرم وہ سیشن منتخب کریں جس سے آپ منسلک ہونا چاہتے ہیں"), + ("powered_by_me", "میں کی طرف سے طاقتور"), + ("outgoing_only_desk_tip", ""), + ("preset_password_warning", ""), + ("Security Alert", "سیکورٹی الرٹ"), + ("My address book", "میری ایڈریس بک"), + ("Personal", "شخصی"), + ("Owner", "مالک"), + ("Set shared password", "پھیلاو پاس ورڈ مرتب کریں"), + ("Exist in", "موجود ہے"), + ("Read-only", "صرف پڑھنے کے لیے"), + ("Read/Write", "پڑھنے/لکھنے"), + ("Full Control", "پورا کنٹرول"), + ("share_warning_tip", ""), + ("Everyone", "ہر کوئی"), + ("ab_web_console_tip", ""), + ("allow-only-conn-window-open-tip", ""), + ("no_need_privacy_mode_no_physical_displays_tip", ""), + ("Follow remote cursor", "ریموٹ کرسر کی پیروی کریں"), + ("Follow remote window focus", "ریموٹ ونڈو فوکس کی پیروی کریں"), + ("default_proxy_tip", ""), + ("no_audio_input_device_tip", ""), + ("Incoming", "آنے والے"), + ("Outgoing", "بھیجے جا رہے"), + ("Clear Wayland screen selection", "Wayland سکرین کی انتخاب صاف کریں"), + ("clear_Wayland_screen_selection_tip", ""), + ("confirm_clear_Wayland_screen_selection_tip", ""), + ("android_new_voice_call_tip", ""), + ("texture_render_tip", ""), + ("Use texture rendering", "ٹیکسچر رینڈرنگ کا استعمال کریں"), + ("Floating window", "فلوٹنگ ونڈو"), + ("floating_window_tip", ""), + ("Keep screen on", "سکرین کو آن رکھیں"), + ("Never", "کبھی نہیں"), + ("During controlled", "کنٹرول کے دوران"), + ("During service is on", "سروس فعال ہو تو"), + ("Capture screen using DirectX", "DirectX کا استعمال کرکے سکرین کی تصویر لیں"), + ("Back", "واپس"), + ("Apps", "ایپس"), + ("Volume up", "آواز بڑھائیں"), + ("Volume down", "آواز کم کریں"), + ("Power", "پاور"), + ("Telegram bot", "ٹیلیگرام بات"), + ("enable-bot-tip", ""), + ("enable-bot-desc", ""), + ("cancel-2fa-confirm-tip", ""), + ("cancel-bot-confirm-tip", ""), + ("About RustDesk", "رستڈیسک کے بارے میں"), + ("Send clipboard keystrokes", "کلپ بورڈ کی چابیاں بھیجیں"), + ("network_error_tip", ""), + ("Unlock with PIN", "PIN کے ساتھ انلاک کریں"), + ("Requires at least {} characters", "کم از کم {} حروف کی ضرورت ہے"), + ("Wrong PIN", "غلط PIN"), + ("Set PIN", "PIN سیٹ کریں"), + ("Enable trusted devices", "معتبر آلے فعال کریں"), + ("Manage trusted devices", "معتبر آلے مینیج کریں"), + ("Platform", "پلیٹ فارم"), + ("Days remaining", "دن باقی"), + ("enable-trusted-devices-tip", ""), + ("Parent directory", "والد ڈائرکٹری"), + ("Resume", "جاری رکھیں"), + ("Invalid file name", "غلط فائل کا نام"), + ("one-way-file-transfer-tip", ""), + ("Authentication Required", "توثیق کی ضرورت ہے"), + ("Authenticate", "توثیق کریں"), + ("web_id_input_tip", ""), + ("Download", "ڈاؤن لوڈ کریں"), + ("Upload folder", "اپ لوڈ فولڈر"), + ("Upload files", "فائلیں اپ لوڈ کریں"), + ("Clipboard is synchronized", "کلپ بورڈ مطابق ہے"), + ("Update client clipboard", "کلپ بورڈ کو اپ ڈیٹ کریں"), + ("Untagged", "غیر تعلق یافتہ"), + ("new-version-of-{}-tip", ""), + ("Accessible devices", "قابلِ رسائی والے آلے"), + ("upgrade_remote_rustdesk_client_to_{}_tip", ""), + ("d3d_render_tip", ""), + ("Use D3D rendering", "D3D رینڈرنگ کا استعمال کریں"), + ("Printer", "پرنٹر"), + ("printer-os-requirement-tip", ""), + ("printer-requires-installed-{}-client-tip", ""), + ("printer-{}-not-installed-tip", ""), + ("printer-{}-ready-tip", ""), + ("Install {} Printer", " {} پرنٹر انسٹال کریں"), + ("Outgoing Print Jobs", "بیرونی پرنٹ کام"), + ("Incoming Print Jobs", "اندر کے پرنٹ کام"), + ("Incoming Print Job", "اندر کا پرنٹ کام"), + ("use-the-default-printer-tip", ""), + ("use-the-selected-printer-tip", ""), + ("auto-print-tip", ""), + ("print-incoming-job-confirm-tip", ""), + ("remote-printing-disallowed-tile-tip", ""), + ("remote-printing-disallowed-text-tip", ""), + ("save-settings-tip", ""), + ("dont-show-again-tip", " ٹپ دوبارہ نہ دکھائیں "), + ("Take screenshot", "اسکرین شاٹ لیں"), + ("Taking screenshot", "اسکرین شاٹ لے رہے ہیں"), + ("screenshot-merged-screen-not-supported-tip", ""), + ("screenshot-action-tip", "اسکرین شاٹ ایکشن ٹپ"), + ("Save as", "حفظ کے طور پر"), + ("Copy to clipboard", "کلپ بورڈ پر کاپی کریں"), + ("Enable remote printer", "ریموٹ پرنٹر کو فعال کریں"), + ("Downloading {}", "ڈاؤن لوڈ ہو رہا ہے {}"), + ("{} Update", "{} اپ ڈیٹ"), + ("{}-to-update-tip", ""), + ("download-new-version-failed-tip", ""), + ("Auto update", "خودکار اپ ڈیٹ"), + ("update-failed-check-msi-tip", ""), + ("websocket_tip", ""), + ("Use WebSocket", "WebSocket استعمال کریں"), + ("Trackpad speed", "ٹریک پیڈ کی رفتار"), + ("Default trackpad speed", "ڈیفالٹ ٹریک پیڈ کی رفتار"), + ("Numeric one-time password", "عددی ایک مرتبہ کے لیے پاس ورڈ"), + ("Enable IPv6 P2P connection", "IPv6 P2P کنکشن کو فعال کریں"), + ("Enable UDP hole punching", "UDP ہول پنچنگ کو فعال کریں"), + ("View camera", "کیرہ دیکھیں"), + ("Enable camera", "کیرہ کو فعال کریں"), + ("No cameras", "کوئی کیرہ نہیں"), + ("view_camera_unsupported_tip", "کیرہ دیکھنے کی اجازت نہیں ہے"), + ("Terminal", "ٹرمنل"), + ("Enable terminal", "ٹرمنل کو فعال کریں"), + ("New tab", "نیا ٹیب"), + ("Keep terminal sessions on disconnect", "ڈسکنیکٹ پر ٹرمنل سیشنز کو رکھیں"), + ("Terminal (Run as administrator)", "ٹرمنل (ایڈمنسٹریٹر کے طور پر چلائیں)"), + ("terminal-admin-login-tip", "ٹرمنل ایڈمنسٹریٹر لاگ ان تیپ"), + ("Failed to get user token.", "صارف ٹوکن حاصل کرنے میں ناکام"), + ("Incorrect username or password.", "غلط صارف نام یا پاس ورڈ"), + ("The user is not an administrator.", "صارف ایڈمنسٹریٹر نہیں ہے"), + ("Failed to check if the user is an administrator.", "صارف ایڈمنسٹریٹر ہے یا نہیں چیک کرنے میں ناکام"), + ("Supported only in the installed version.", "صرف انسٹال شدہ ورژن میں معاونت کی جاتی ہے۔"), + ("elevation_username_tip", ""), + ("Preparing for installation ...", "انسٹالیشن کی تیاری ..."), + ("Show my cursor", "میرا کرسر دکھائیں"), + ("Scale custom", "اپنی مرضی کے مطابق پیمانہ"), + ("Custom scale slider", "اپنی مرضی کے مطابق پیمانہ سلائیڈر"), + ("Decrease", "کم کریں"), + ("Increase", "زیادہ کریں"), + ("Show virtual mouse", "ورچوئل ماؤس دکھائیں"), + ("Virtual mouse size", "ورچوئل ماؤس کا سائز"), + ("Small", "چھوٹا"), + ("Large", "بڑا"), + ("Show virtual joystick", "ورچوئل جوائس اسٹک دکھائیں"), + ("Edit note", "نوٹ میں ترمیم کریں"), + ("Alias", "عرف نام"), + ("ScrollEdge", "اسکرول ایج"), + ("Allow insecure TLS fallback", "غیر محفوظ TLS فالبیک کی اجازت دیں"), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable UDP", "UDP کو غیر فعال کریں"), + ("disable-udp-tip", ""), + ("server-oss-not-support-tip", ""), + ("input note here", "نوٹ یہاں درج کریں"), + ("note-at-conn-end-tip", ""), + ("Show terminal extra keys", "ٹرمنل اضافی کیز دکھائیں"), + ("Relative mouse mode", "رشتہ دار ماؤس موڈ"), + ("rel-mouse-not-supported-peer-tip", ""), + ("rel-mouse-not-ready-tip", ""), + ("rel-mouse-lock-failed-tip", ""), + ("rel-mouse-exit-{}-tip", ""), + ("rel-mouse-permission-lost-tip", ""), + ("Changelog", "تبدیلی کا لاگ"), + ("keep-awake-during-outgoing-sessions-label", ""), + ("keep-awake-during-incoming-sessions-label", ""), + ("Continue with {}", "continue-with-{}"), + ("Display Name", "display-name"), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ].iter().cloned().collect(); +} + From 893dc277983a66f815e2937073ac9ef1396885bf Mon Sep 17 00:00:00 2001 From: Rafli Surya Wijaya <260355617@qq.com> Date: Tue, 25 Aug 2026 11:21:34 +0800 Subject: [PATCH 55/72] docs(readme): fix broken Screenshots section anchor link (#15964) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 08f3f9d57..a593a191f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ BuildDockerStructure • - Snapshot
+ Screenshots
[Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Dansk] | [Ελληνικά] | [Türkçe] | [Norsk] | [Română]
We need your help to translate this README, RustDesk UI and RustDesk Doc to your native language

From 0d917c6fa15871b77791735851a483f5fc011418 Mon Sep 17 00:00:00 2001 From: fufesou Date: Tue, 25 Aug 2026 18:25:49 +0800 Subject: [PATCH 56/72] fix: remove dup translations (#15967) Signed-off-by: fufesou --- src/lang/ur.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/lang/ur.rs b/src/lang/ur.rs index f40ec817c..5f6e1c235 100644 --- a/src/lang/ur.rs +++ b/src/lang/ur.rs @@ -134,7 +134,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Insert Ctrl + Alt + Del", "Ctrl + Alt + Del داخل کریں"), ("Insert Lock", "لاک داخل کریں"), ("Refresh", "ریفریش کریں"), - ("ID", ""), ("ID does not exist", "ID موجود نہیں ہے"), ("Failed to connect to rendezvous server", "رینڈوز سرور سے کنکشن کرنے میں ناکام"), ("Please try later", "براہِ مہربانی بعد میں کوشش کریں"), @@ -189,7 +188,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Enter Remote ID", "ریموٹ آئی ڈی درج کریں"), ("Enter your password", "اپنا پاس ورڈ درج کریں"), ("Logging in...", "لاگ ان ہو رہا ہے..."), - ("Login", "لاگ ان کریں"), ("Enable RDP session sharing", "RDP سیشن شیئرنگ کو فعال کریں"), ("Auto Login", "خودکار لاگ ان"), ("Enable direct IP access", "براہِ راست IP رسائی کو فعال کریں"), @@ -215,7 +213,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Connect via relay", "ریلے کے ذریعے کنیکٹ کریں"), ("Always connect via relay", "ہمیشہ ریلے کے ذریعے کنیکٹ کریں"), ("whitelist_tip", ""), - ("Login", "لاگ ان"), + ("Login", "لاگ ان کریں"), ("Verify", "تصدیق کریں"), ("Remember me", "یاد رکھیں"), ("Trust this device", "اس ڈیوائس پر اعتماد کریں"), @@ -563,7 +561,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Plug out all", "تمام پلگ آؤٹ کریں"), ("True color (4:4:4)", "اصل رنگ (4:4:4)"), ("Enable blocking user input", "صارف ان پٹ کو روکنے کی اجازت دیں"), - ("Enable blocking user input", "کاربر ان پٹ کو روکنے کی اجازت دیں"), ("id_input_tip", ""), ("privacy_mode_impl_mag_tip", ""), ("privacy_mode_impl_virtual_display_tip", ""), From cec4085238ee723b75095e94014e8595f374688b Mon Sep 17 00:00:00 2001 From: Kino Date: Tue, 25 Aug 2026 19:53:56 +0800 Subject: [PATCH 57/72] Bump aom to v3.14.1 (#15883) * Bump aom to v3.14.1 * Remove oboe dependency in vcpkg.json --- .github/workflows/ci.yml | 2 +- .github/workflows/flutter-build.yml | 4 ++-- .github/workflows/playground.yml | 2 +- build.rs | 1 - res/vcpkg/aom/aom-uninitialized-pointer-3.9.1.diff | 13 +++++++++++++ res/vcpkg/aom/aom-uninitialized-pointer.diff | 6 +++--- res/vcpkg/aom/portfile.cmake | 11 +++++------ res/vcpkg/aom/vcpkg.json | 2 +- vcpkg.json | 6 +----- 9 files changed, 27 insertions(+), 20 deletions(-) create mode 100644 res/vcpkg/aom/aom-uninitialized-pointer-3.9.1.diff diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e8373cdc..ecc9ee782 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ env: # CICD_INTERMEDIATES_DIR: "_cicd-intermediates" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" # for multiarch gcc compatibility - VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" + VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d" on: workflow_dispatch: diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 3a76412da..759677cca 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -36,13 +36,13 @@ env: FLUTTER_ELINUX_VERSION: "3.16.9" TAG_NAME: "${{ inputs.upload-tag }}" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - # vcpkg version: 2025.08.27 + # vcpkg version: 2026.07.29 # If we change the `VCPKG COMMIT_ID`, please remember: # 1. Call `$VCPKG_ROOT/vcpkg x-update-baseline` to update the baseline in `vcpkg.json`. # Or we may face build issue like # https://github.com/rustdesk/rustdesk/actions/runs/14414119794/job/40427970174 # 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`. - VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" + VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d" ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version VERSION: "1.4.9" NDK_VERSION: "r28c" diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml index 765bcf7f7..f8e408f76 100644 --- a/.github/workflows/playground.yml +++ b/.github/workflows/playground.yml @@ -16,7 +16,7 @@ env: FLUTTER_ELINUX_VERSION: "3.16.9" TAG_NAME: "nightly" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" + VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d" VERSION: "1.4.9" NDK_VERSION: "r26d" #signing keys env variable checks diff --git a/build.rs b/build.rs index 92fb1f4b4..ec87831c0 100644 --- a/build.rs +++ b/build.rs @@ -72,7 +72,6 @@ fn install_android_deps() { path.join("lib").to_str().unwrap() ); println!("cargo:rustc-link-lib=ndk_compat"); - println!("cargo:rustc-link-lib=oboe"); println!("cargo:rustc-link-lib=c++"); println!("cargo:rustc-link-lib=OpenSLES"); } diff --git a/res/vcpkg/aom/aom-uninitialized-pointer-3.9.1.diff b/res/vcpkg/aom/aom-uninitialized-pointer-3.9.1.diff new file mode 100644 index 000000000..37a7166cc --- /dev/null +++ b/res/vcpkg/aom/aom-uninitialized-pointer-3.9.1.diff @@ -0,0 +1,13 @@ +diff --git a/build/cmake/aom_configure.cmake b/build/cmake/aom_configure.cmake +index aaef2c310..5500ad4a3 100644 +--- a/build/cmake/aom_configure.cmake ++++ b/build/cmake/aom_configure.cmake +@@ -309,6 +309,8 @@ if(MSVC) + + # Disable MSVC warnings that suggest making code non-portable. + add_compiler_flag_if_supported("/wd4996") ++ # Disable MSVC warnings for potentially uninitialized local pointer variable. ++ add_compiler_flag_if_supported("/wd4703") + if(ENABLE_WERROR) + add_compiler_flag_if_supported("/WX") + endif() diff --git a/res/vcpkg/aom/aom-uninitialized-pointer.diff b/res/vcpkg/aom/aom-uninitialized-pointer.diff index 37a7166cc..0e8c12e21 100644 --- a/res/vcpkg/aom/aom-uninitialized-pointer.diff +++ b/res/vcpkg/aom/aom-uninitialized-pointer.diff @@ -1,7 +1,7 @@ -diff --git a/build/cmake/aom_configure.cmake b/build/cmake/aom_configure.cmake +diff --git a/cmake/aom_configure.cmake b/cmake/aom_configure.cmake index aaef2c310..5500ad4a3 100644 ---- a/build/cmake/aom_configure.cmake -+++ b/build/cmake/aom_configure.cmake +--- a/cmake/aom_configure.cmake ++++ b/cmake/aom_configure.cmake @@ -309,6 +309,8 @@ if(MSVC) # Disable MSVC warnings that suggest making code non-portable. diff --git a/res/vcpkg/aom/portfile.cmake b/res/vcpkg/aom/portfile.cmake index f7b1e3c43..502d31a7c 100644 --- a/res/vcpkg/aom/portfile.cmake +++ b/res/vcpkg/aom/portfile.cmake @@ -9,25 +9,24 @@ get_filename_component(PERL_PATH ${PERL} DIRECTORY) vcpkg_add_to_path(${PERL_PATH}) if(DEFINED ENV{USE_AOM_391}) + set(AOM_CONFIG_PATH "lib/cmake/aom") vcpkg_from_git( OUT_SOURCE_PATH SOURCE_PATH URL "https://aomedia.googlesource.com/aom" REF 8ad484f8a18ed1853c094e7d3a4e023b2a92df28 # 3.9.1 PATCHES - aom-uninitialized-pointer.diff + aom-uninitialized-pointer-3.9.1.diff aom-avx2.diff aom-install.diff ) else() + set(AOM_CONFIG_PATH "lib/cmake/AOM") vcpkg_from_git( OUT_SOURCE_PATH SOURCE_PATH URL "https://aomedia.googlesource.com/aom" - REF 10aece4157eb79315da205f39e19bf6ab3ee30d0 # 3.12.1 + REF 03087864cf4bea6abb0d28f95cf7843511413d8f # 3.14.1 PATCHES aom-uninitialized-pointer.diff - # aom-avx2.diff - # Can be dropped when https://bugs.chromium.org/p/aomedia/issues/detail?id=3029 is merged into the upstream - aom-install.diff ) endif() @@ -67,7 +66,7 @@ if(VCPKG_TARGET_IS_WINDOWS) endif() # Move cmake configs -vcpkg_cmake_config_fixup(CONFIG_PATH lib/cmake/${PORT}) +vcpkg_cmake_config_fixup(CONFIG_PATH ${AOM_CONFIG_PATH}) # Remove duplicate files file(REMOVE_RECURSE ${CURRENT_PACKAGES_DIR}/debug/include diff --git a/res/vcpkg/aom/vcpkg.json b/res/vcpkg/aom/vcpkg.json index 70a12d83e..8d69a88a3 100644 --- a/res/vcpkg/aom/vcpkg.json +++ b/res/vcpkg/aom/vcpkg.json @@ -1,6 +1,6 @@ { "name": "aom", - "version-semver": "3.12.1", + "version-semver": "3.14.1", "port-version": 0, "description": "AV1 codec library", "homepage": "https://aomedia.googlesource.com/aom", diff --git a/vcpkg.json b/vcpkg.json index cd282fc1c..d1cd4044a 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -25,10 +25,6 @@ "host": false, "platform": "windows & arm64" }, - { - "name": "oboe", - "platform": "android" - }, { "name": "opus", "host": true @@ -91,7 +87,7 @@ "vcpkg-configuration": { "default-registry": { "kind": "builtin", - "baseline": "120deac3062162151622ca4860575a33844ba10b" + "baseline": "9e593bb18ea69cc5095e012465dcd675a822ed0d" }, "overlay-ports": [ "./res/vcpkg" From 3f207e91f6061b637f704f94074ee487b030625f Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Wed, 26 Aug 2026 07:26:26 -0300 Subject: [PATCH 58/72] fix(linux): a session logout should hand the peer to the login screen (#15905) * fix(linux): a session logout should hand the peer to the login screen Logging out closes every window in the session, the connection manager's included, and its close handler kicks every peer with the reason a person gets when they disconnect one by hand. That reason is the one thing the client never retries on, so the remote session dies on a frozen frame instead of reconnecting to the greeter that is already there. The close carries nothing to tell the two apart: measured on KDE, the CM receives no signal and logind still reports the session active at that instant, and the server is killed within a few hundred ms either way, so neither a state check nor a grace period can decide it. What is distinguishable is the ACTION: disconnecting a peer is not the same event as this window going away. So the window-close path now says so, and the server ends the session without poisoning the retry; the Disconnect button and the app's own close control keep kicking exactly as before. Linux only, since that is where a logout closes the window. Verified on plasma/sddm with a client attached: a logout now reconnects to the greeter with no dialog, while closing the manager window still shows Closed manually by the peer. * fix(linux): close the tunnel too, and keep the web build compiling Three seams the first pass missed. The web bridge is hand written, not generated, so the new call needs its stub there or flutter build web stops compiling - and that job is disabled in CI, so it would have gone green. try_port_forward_loop is a second consumer of the same channel and only knew Close, so a forwarded tunnel outlived the window it was supposed to die with. And the variant had landed inside the DRM section, whose comment says everything below it is drm-gated. --- flutter/lib/desktop/pages/server_page.dart | 17 ++++++++++++++++- flutter/lib/models/server_model.dart | 10 +++++++--- flutter/lib/web/bridge.dart | 4 ++++ src/flutter_ffi.rs | 9 +++++++++ src/ipc.rs | 9 +++++++++ src/server/connection.rs | 19 +++++++++++++++++++ src/ui_cm_interface.rs | 9 +++++++++ 7 files changed, 73 insertions(+), 4 deletions(-) diff --git a/flutter/lib/desktop/pages/server_page.dart b/flutter/lib/desktop/pages/server_page.dart index a814b9f7e..b1ca18b2b 100644 --- a/flutter/lib/desktop/pages/server_page.dart +++ b/flutter/lib/desktop/pages/server_page.dart @@ -22,6 +22,14 @@ import '../../models/file_model.dart'; import '../../models/platform_model.dart'; import '../../models/server_model.dart'; +/// Set only by this window's own close control, and only once the user has confirmed. Any other +/// way the window can go - a session logout closing every window, the window manager, a native +/// title-bar button this app does not draw - leaves it false, which is the honest answer: +/// nothing in that close says who asked for it. It lives at file scope because the control that +/// sets it (`ConnectionManagerState`) and the handler that reads it (`_DesktopServerPageState`) +/// are different widgets. +bool _cmClosedByOperator = false; + class DesktopServerPage extends StatefulWidget { const DesktopServerPage({Key? key}) : super(key: key); @@ -55,7 +63,10 @@ class _DesktopServerPageState extends State @override void onWindowClose() { - Future.wait([gFFI.serverModel.closeAll(), gFFI.close()]).then((_) { + // Other platforms keep the old behaviour exactly: the ambiguity this guards against is a + // Linux session logout, which closes every window in the session. + final byOperator = _cmClosedByOperator || !isLinux; + Future.wait([gFFI.serverModel.closeAll(byOperator: byOperator), gFFI.close()]).then((_) { if (isMacOS) { RdPlatformChannel.instance.terminate(); } else { @@ -327,6 +338,7 @@ class ConnectionManagerState extends State var tabController = gFFI.serverModel.tabController; final connLength = tabController.length; if (connLength <= 1) { + _cmClosedByOperator = true; windowManager.close(); return true; } else { @@ -338,6 +350,9 @@ class ConnectionManagerState extends State res = await closeConfirmDialog(); } if (res) { + // After the dialog, never before it: an external close while it is open must not + // inherit an intent the user had not expressed yet. + _cmClosedByOperator = true; windowManager.close(); } return res; diff --git a/flutter/lib/models/server_model.dart b/flutter/lib/models/server_model.dart index 40c94fcf5..6e78ad17f 100644 --- a/flutter/lib/models/server_model.dart +++ b/flutter/lib/models/server_model.dart @@ -738,9 +738,13 @@ class ServerModel with ChangeNotifier { } } - Future closeAll() async { - await Future.wait( - _clients.map((client) => bind.cmCloseConnection(connId: client.id))); + /// `byOperator` false means the CM's window went away rather than a person asking for the + /// peers to go. The sessions end either way; only the close reason differs, and with it + /// whether the peer is allowed to reconnect. See `ipc::Data::CmWindowClosed`. + Future closeAll({bool byOperator = true}) async { + await Future.wait(_clients.map((client) => byOperator + ? bind.cmCloseConnection(connId: client.id) + : bind.cmCloseConnectionWindow(connId: client.id))); _clients.clear(); tabController.state.value.tabs.clear(); if (isAndroid) androidUpdatekeepScreenOn(); diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index f4a082941..087d300c1 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -1373,6 +1373,10 @@ class RustdeskImpl { throw UnimplementedError("cmLoginRes"); } + Future cmCloseConnectionWindow({required int connId, dynamic hint}) { + throw UnimplementedError("cmCloseConnectionWindow"); + } + Future cmCloseConnection({required int connId, dynamic hint}) { throw UnimplementedError("cmCloseConnection"); } diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 4064162ff..6d093cfab 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -2197,6 +2197,15 @@ pub fn cm_close_connection(conn_id: i32) { crate::ui_cm_interface::close(conn_id); } +/// The CM window closed. On Linux that is ambiguous - a logout closes it the same way a person +/// does - so it ends the session without the no-retry reason; elsewhere it is a plain close. +pub fn cm_close_connection_window(conn_id: i32) { + #[cfg(target_os = "linux")] + crate::ui_cm_interface::close_window(conn_id); + #[cfg(all(not(target_os = "linux"), not(target_os = "ios")))] + crate::ui_cm_interface::close(conn_id); +} + pub fn cm_remove_disconnected_connection(conn_id: i32) { #[cfg(not(any(target_os = "ios")))] crate::ui_cm_interface::remove(conn_id); diff --git a/src/ipc.rs b/src/ipc.rs index 9e3faab63..804b89db6 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -501,6 +501,15 @@ pub enum Data { ControlPermissionsRemoteModify(Option), #[cfg(target_os = "windows")] FileTransferEnabledState(Option), + /// CM -> server: the connection manager's WINDOW went away, which is not the same event + /// as the operator disconnecting a peer. Linux only, and deliberately: there a session + /// logout closes every window, and the close arrives at the CM indistinguishable from a + /// person clicking it - measured on KDE, the CM gets no signal and logind still reports the + /// session active. So the ambiguous case ends the session WITHOUT the no-retry reason and + /// the peer is allowed to reconnect (landing on the greeter after a logout), while the + /// explicit Disconnect button keeps sending `Close` and kicking for good. + #[cfg(target_os = "linux")] + CmWindowClosed, // --- DRM/KMS capture (opt-in `drm` feature) over the `_drm` service-scoped channel --- // All of the following are `cfg(all(linux, drm))`, so the drm-off IPC wire is byte-identical // to upstream. Protocol on `_drm`: on connect the root service sends `DrmDisplayList`, the diff --git a/src/server/connection.rs b/src/server/connection.rs index 649f045fc..adcab4c88 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -642,6 +642,18 @@ impl Connection { conn.on_close("connection manager", true).await; break; } + // The connection manager's window went away rather than a person + // disconnecting this peer. End the session exactly as above, but do not + // send the manual close reason: it is the one thing that stops the peer + // from retrying, and on a logout the retry is the whole point - it is + // what puts the peer back on the login screen a moment later. + #[cfg(target_os = "linux")] + ipc::Data::CmWindowClosed => { + conn.chat_unanswered = false; // seen + conn.file_transferred = false; //seen + conn.on_close("connection manager window closed", true).await; + break; + } ipc::Data::CmErr(e) => { if e != "expected" { // cm closed before connection @@ -1201,6 +1213,13 @@ impl Connection { ipc::Data::Close => { bail!("Close requested from connection manager"); } + // Same end as above: a tunnel must not outlive the window either. + // Only the reason differs, and a port forward carries none - the + // peer sees the tunnel drop and decides for itself. + #[cfg(target_os = "linux")] + ipc::Data::CmWindowClosed => { + bail!("Connection manager window closed"); + } ipc::Data::CmErr(e) => { log::error!("Connection manager error: {e}"); bail!("{e}"); diff --git a/src/ui_cm_interface.rs b/src/ui_cm_interface.rs index b62f59c54..1474ce093 100644 --- a/src/ui_cm_interface.rs +++ b/src/ui_cm_interface.rs @@ -377,6 +377,15 @@ pub fn close(id: i32) { }; } +/// Like `close`, but says the CM's WINDOW closed rather than a person disconnecting this peer. +/// See `ipc::Data::CmWindowClosed`. +#[cfg(target_os = "linux")] +pub fn close_window(id: i32) { + if let Some(client) = CLIENTS.read().unwrap().get(&id) { + allow_err!(client.tx.send(Data::CmWindowClosed)); + }; +} + #[inline] pub fn remove(id: i32) { CLIENTS.write().unwrap().remove(&id); From 7c6e661fcc958ad6532647a9a6c0c95e224383c8 Mon Sep 17 00:00:00 2001 From: Jade <5164609+gnosticJade@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:41:24 -0600 Subject: [PATCH 59/72] fix(linux): Set AppIndicator ID for tray-icon (#15981) * set static AppIndicator ID in tray-icon init allows DEs, eg. KDE to 'remember' the user's configuration of tray hidden/unhidden. see: https://github.com/rustdesk/rustdesk/discussions/15208 Signed-off-by: Jade <5164609+gnosticJade@users.noreply.github.com> * Update tray.rs --------- Signed-off-by: Jade <5164609+gnosticJade@users.noreply.github.com> Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com> --- src/tray.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tray.rs b/src/tray.rs index 0b7e38542..4ccc921bd 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -153,6 +153,7 @@ fn make_tray() -> hbb_common::ResultType<()> { // We create the icon once the event loop is actually running // to prevent issues like https://github.com/tauri-apps/tray-icon/issues/90 let mut builder = TrayIconBuilder::new() + .with_id(create::get_app_name().to_lowercase()) .with_menu(Box::new(tray_menu.clone())) .with_tooltip(tooltip(0)) .with_icon(icon.clone()); From fd471fcf028eaef38a447f03a5a482251208117b Mon Sep 17 00:00:00 2001 From: fufesou Date: Thu, 27 Aug 2026 11:08:58 +0800 Subject: [PATCH 60/72] fix: show speed in desktop file transfer status (#15980) * fix: show speed in desktop file transfer status Signed-off-by: fufesou * fix: move file transfer speed beside progress bar Signed-off-by: fufesou * fix: move file transfer speed into progress bar Signed-off-by: fufesou * fix: refine file transfer speed display Signed-off-by: fufesou * fix: adapt file transfer progress text colors Signed-off-by: fufesou * fix: reduce file transfer speed text weight Signed-off-by: fufesou --------- Signed-off-by: fufesou --- .../lib/desktop/pages/file_manager_page.dart | 34 ++++++++++++++++++- .../lib/mobile/pages/file_manager_page.dart | 3 +- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/flutter/lib/desktop/pages/file_manager_page.dart b/flutter/lib/desktop/pages/file_manager_page.dart index cf97351b3..e1130fdaa 100644 --- a/flutter/lib/desktop/pages/file_manager_page.dart +++ b/flutter/lib/desktop/pages/file_manager_page.dart @@ -278,7 +278,39 @@ class _FileManagerPageState extends State item.state != JobState.inProgress, child: LinearPercentIndicator( animateFromLastPercent: true, - center: Text(item.percentText), + center: SizedBox.expand( + child: ShaderMask( + blendMode: BlendMode.srcATop, + shaderCallback: (bounds) => + LinearGradient( + colors: [ + Colors.white, + Colors.transparent, + ], + stops: [item.percent, item.percent], + ).createShader(bounds), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text.rich( + TextSpan( + text: item.percentText, + children: [ + if (item.recvJobRes) + TextSpan( + text: + ' ${readableFileSize(item.speed)}/s', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w300, + color: MyTheme.darkGray, + ), + ), + ], + ), + ), + ), + ), + ), barRadius: Radius.circular(15), percent: item.percent, progressColor: MyTheme.accent, diff --git a/flutter/lib/mobile/pages/file_manager_page.dart b/flutter/lib/mobile/pages/file_manager_page.dart index 1e793bca7..982a4c805 100644 --- a/flutter/lib/mobile/pages/file_manager_page.dart +++ b/flutter/lib/mobile/pages/file_manager_page.dart @@ -366,8 +366,7 @@ class _FileManagerPageState extends State { return BottomSheetBody( leading: CircularProgressIndicator(), title: translate("Waiting"), - text: - "${translate("Speed")}: ${readableFileSize(activeJob.speed)}/s", + text: "${readableFileSize(activeJob.speed)}/s", onCanceled: () { model.jobController.cancelJob(activeJob.id); jobTable.clear(); From 7220f004102625d59905964f9d15db670cf4653c Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:39:59 +0800 Subject: [PATCH 61/72] fix(linux): a Wayland session without XAUTHORITY is not incomplete (#15978) Fixes #15952. Hyprland runs Xwayland without exporting `XAUTHORITY`, and `get_display_xauth_xwayland` only returns once it has both `DISPLAY` and `XAUTHORITY`. On such a session that condition is never met, so every refresh runs the retry loop to the end: 10 rounds x 6 process patterns x 4 variables = 240 `get_env` calls, each a `sh -c` pipeline of ~12 processes starting with a full `ps -u -f`. That is ~2900 fork/exec per refresh, and the service loop repeats every 500 ms. The reporter measured a full core on a low-end laptop and ~60% of a core on a 13600KF. The Wayland side answers for such a session, so accept `DISPLAY` together with either `XAUTHORITY` or `WAYLAND_DISPLAY` + `DBUS_SESSION_BUS_ADDRESS`. The portal answers on the first pattern, which ends the walk there, as it already did on desktops that do export an xauth. The loop also assigned all four variables unconditionally per pattern, so the patterns that do not run on a given desktop blanked out what an earlier one had answered with -- the portal's valid `DISPLAY=:1` included. That is why the `--server` was then started with no `WAYLAND_DISPLAY` and no `DBUS_SESSION_BUS_ADDRESS`. Candidates are now taken from one pattern as a whole and ranked, so a later pattern replaces an earlier answer only by being better, and a session that can only offer a compositor and a bus still keeps them. A compositor that starts Xwayland on demand shows the same shape from the other side: the portal came up before Xwayland did, so its environment carries a valid `WAYLAND_DISPLAY` and `DBUS_SESSION_BUS_ADDRESS` but no `DISPLAY`, and no pattern here may ever produce one. That pair alone is a session the child server can be started against -- it is exactly what `get_display_xauth_wayland` returns on -- so it outranks a bare `DISPLAY` and ends the retrying, while the rest of the round still looks for something that completes the session. Not specific to the drm build: the function is not feature-gated, and the commit the report points at does not touch it. Claude-Session: https://claude.ai/code/session_01Q5egQpH4q4GoXJiuMoTJ5t Co-authored-by: Claude Opus 5 (1M context) --- src/platform/linux.rs | 54 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/src/platform/linux.rs b/src/platform/linux.rs index f0979c2d1..247f70b01 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1957,6 +1957,14 @@ mod desktop { const ENV_KEY_WAYLAND_DISPLAY: &str = "WAYLAND_DISPLAY"; const ENV_KEY_DBUS_SESSION_BUS_ADDRESS: &str = "DBUS_SESSION_BUS_ADDRESS"; + /// A compositor that runs Xwayland without exporting `XAUTHORITY` (wlroots, e.g. Hyprland) + /// still hands out a usable session through the Wayland side. Requiring xauth there never + /// succeeded, so every refresh ran the retry loop to the end, 240 shell pipelines at a time. + /// https://github.com/rustdesk/rustdesk/issues/15952 + fn is_session_env_complete(display: &str, xauth: &str, wl_display: &str, dbus: &str) -> bool { + !display.is_empty() && (!xauth.is_empty() || (!wl_display.is_empty() && !dbus.is_empty())) + } + #[derive(Debug, Clone, Default)] pub struct Desktop { pub sid: String, @@ -2023,15 +2031,51 @@ mod desktop { PLASMA_KDED, tray.as_str(), ]; + self.display.clear(); + self.xauth.clear(); + self.wl_display.clear(); + self.dbus.clear(); + let mut kept = 0u8; for proc in display_proc { - self.display = get_env(ENV_KEY_DISPLAY, &self.uid, proc); - self.xauth = get_env(ENV_KEY_XAUTHORITY, &self.uid, proc); - self.wl_display = get_env(ENV_KEY_WAYLAND_DISPLAY, &self.uid, proc); - self.dbus = get_env(ENV_KEY_DBUS_SESSION_BUS_ADDRESS, &self.uid, proc); - if !self.display.is_empty() && !self.xauth.is_empty() { + let display = get_env(ENV_KEY_DISPLAY, &self.uid, proc); + let xauth = get_env(ENV_KEY_XAUTHORITY, &self.uid, proc); + let wl_display = get_env(ENV_KEY_WAYLAND_DISPLAY, &self.uid, proc); + let dbus = get_env(ENV_KEY_DBUS_SESSION_BUS_ADDRESS, &self.uid, proc); + // Take a candidate whole and keep the best seen. Assigning each variable + // unconditionally let a pattern that does not run on this desktop blank out + // the values an earlier one had answered with, which is how a session with a + // working portal ended up starting its `--server` with no compositor and no + // bus at all. The Wayland-only rank is what a session whose Xwayland exports + // no `XAUTHORITY` can still offer. + let complete = is_session_env_complete(&display, &xauth, &wl_display, &dbus); + let rank = if complete { + 3 + } else if !wl_display.is_empty() && !dbus.is_empty() { + 2 + } else if !display.is_empty() { + 1 + } else { + 0 + }; + if rank > kept { + kept = rank; + self.display = display; + self.xauth = xauth; + self.wl_display = wl_display; + self.dbus = dbus; + } + if complete { return; } } + // The Wayland pair on its own is a session the child server can be started + // against -- it is what `get_display_xauth_wayland` returns on. Retrying is for a + // session that has not finished coming up, and a compositor whose Xwayland starts + // on demand may never export a `DISPLAY` for this walk to find, so waiting ten + // more rounds for one costs the whole probe again on every refresh. + if kept >= 2 { + break; + } sleep_millis(300); } } From e9b81e347599b61ed7436c7d521af3a935582ffc Mon Sep 17 00:00:00 2001 From: rustdesk Date: Thu, 27 Aug 2026 11:47:36 +0800 Subject: [PATCH 62/72] typo --- src/tray.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tray.rs b/src/tray.rs index 4ccc921bd..f585b3f42 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -153,7 +153,7 @@ fn make_tray() -> hbb_common::ResultType<()> { // We create the icon once the event loop is actually running // to prevent issues like https://github.com/tauri-apps/tray-icon/issues/90 let mut builder = TrayIconBuilder::new() - .with_id(create::get_app_name().to_lowercase()) + .with_id(crate::get_app_name().to_lowercase()) .with_menu(Box::new(tray_menu.clone())) .with_tooltip(tooltip(0)) .with_icon(icon.clone()); From 0b08a83d4b6d4d1ebeea98a0b6e94f46bb3ff33e Mon Sep 17 00:00:00 2001 From: fufesou Date: Thu, 27 Aug 2026 13:01:11 +0800 Subject: [PATCH 63/72] fix(file-transfer): improve large directory loading (#15830) * fix(file-transfer): improve large directory loading Signed-off-by: fufesou * fix(file-transfer): avoid failing newer directory reads Track each remote directory request by its registered completer and only remove the task when it still matches, preventing stale failures from affecting newer requests for the same path. Signed-off-by: fufesou * fix(file-transfer): handle slow directory listings safely Signed-off-by: fufesou * fix(file transfer): correlate directory responses with requests Signed-off-by: fufesou * fix(file transfer): prevent automatic directory responses from matching requests Signed-off-by: fufesou * fix(file-transfer): handle large remote directory listings reliably - build file rows lazily - register remote reads before sending requests - handle Home paths, stale responses, errors, and timeouts - serialize same-path reads with different hidden-file options Signed-off-by: fufesou * fix(file transfer): reduce diffs Signed-off-by: fufesou * fix: build Signed-off-by: fufesou * fix: invalidate pending dir reads on reconnect Signed-off-by: fufesou * test(file-transfer): cover remote directory read lifecycle Signed-off-by: fufesou --------- Signed-off-by: fufesou --- .../lib/desktop/pages/file_manager_page.dart | 5 +- flutter/lib/models/file_model.dart | 165 +++++++--- flutter/test/file_model_test.dart | 281 ++++++++++++++++++ src/ui_cm_interface.rs | 34 ++- 4 files changed, 440 insertions(+), 45 deletions(-) create mode 100644 flutter/test/file_model_test.dart diff --git a/flutter/lib/desktop/pages/file_manager_page.dart b/flutter/lib/desktop/pages/file_manager_page.dart index e1130fdaa..17674c268 100644 --- a/flutter/lib/desktop/pages/file_manager_page.dart +++ b/flutter/lib/desktop/pages/file_manager_page.dart @@ -1126,6 +1126,7 @@ class _FileManagerViewState extends State { return element.name.contains(_searchText.value); }).toList(growable: false) : entries; + // Keep rows lazy so large directories only build visible list items. final rows = filteredEntries.map((entry) { final sizeStr = entry.isFile ? readableFileSize(entry.size.toDouble()) : ""; @@ -1308,7 +1309,7 @@ class _FileManagerViewState extends State { ], ))), ); - }).toList(growable: false); + }); return Column( children: [ @@ -1324,7 +1325,7 @@ class _FileManagerViewState extends State { controller: scrollController, itemExtent: kDesktopFileTransferRowHeight, itemBuilder: (context, index) { - return rows[index]; + return rows.elementAt(index); }, itemCount: rows.length, ), diff --git a/flutter/lib/models/file_model.dart b/flutter/lib/models/file_model.dart index 94f0fcb7b..22bf1eab6 100644 --- a/flutter/lib/models/file_model.dart +++ b/flutter/lib/models/file_model.dart @@ -46,6 +46,12 @@ class JobID { typedef GetSessionID = SessionID Function(); typedef GetDialogManager = OverlayDialogManager? Function(); +typedef ReadRemoteDirectory = Future Function( + SessionID sessionId, String path, bool includeHidden); + +const _kRemoteReadDirTimeout = Duration(seconds: 30); +const _kRemoteSessionChangedError = + 'Remote directory read cancelled because the session changed'; class FileModel { final WeakReference parent; @@ -84,6 +90,7 @@ class FileModel { } Future onReady() async { + fileFetcher.beginRemoteSession(); await evtLoop.onReady(); if (!isWeb) await localController.onReady(); await remoteController.onReady(); @@ -133,7 +140,11 @@ class FileModel { final id = int.tryParse(evt['id']?.toString() ?? ''); if (id != null) { final err = evt['err']?.toString() ?? 'Unknown error'; - fileFetcher.tryCompleteRecursiveTaskWithError(id, err); + if (id == 0) { + fileFetcher.tryCompleteRemoteTaskWithError(err); + } else { + fileFetcher.tryCompleteRecursiveTaskWithError(id, err); + } } // Always call jobController.jobError(evt) to ensure all error events are processed, // even if the event does not have a valid job ID. This allows for generic error handling @@ -350,6 +361,8 @@ class FileController { final history = RxList.empty(growable: true); final sortBy = SortBy.name.obs; var sortAscending = true; + // Incremented for each navigation; only the latest generation applies results. + int _directoryRequestGeneration = 0; final JobController jobController; final WeakReference rootState; @@ -484,12 +497,19 @@ class FileController { path = "$path\\"; } } + final requestGeneration = ++_directoryRequestGeneration; try { final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden); + if (requestGeneration != _directoryRequestGeneration) { + return true; + } fd.format(isWindows, sort: sortBy.value); directory.value = fd; return true; } catch (e) { + if (requestGeneration != _directoryRequestGeneration) { + return true; + } debugPrint("Failed to openDirectory $path: $e"); return false; } @@ -541,6 +561,7 @@ class FileController { void initDirAndHome(Map evt) { try { final fd = FileDirectory.fromJson(jsonDecode(evt['value'])); + final isHomeResponse = fileFetcher.isLikelyRemoteHomeResponse(fd.path); fd.format(options.value.isWindows, sort: sortBy.value); if (fd.id > 0) { final jobIndex = jobController.getJob(fd.id); @@ -556,10 +577,12 @@ class FileController { debugPrint("update receive details: ${fd.path}"); jobController.jobTable.refresh(); } - } else if (options.value.home.isEmpty) { + } else if (options.value.home.isEmpty && isHomeResponse) { options.value.home = fd.path; debugPrint("init remote home: ${fd.path}"); - directory.value = fd; + if (_directoryRequestGeneration == 0) { + directory.value = fd; + } } } catch (e) { debugPrint("initDirAndHome err=$e"); @@ -1362,16 +1385,78 @@ class JobResultListener { } } +class _RemoteReadTask { + final bool includeHidden; + final Completer completer = Completer(); + final Completer released = Completer(); + late final Timer timer; + + _RemoteReadTask(this.includeHidden); +} + class FileFetcher { // Map> localTasks = {}; // now we only use read local dir sync - Map> remoteTasks = {}; + final Map _remoteReadTasks = {}; Map>> remoteEmptyDirsTasks = {}; Map> readRecursiveTasks = {}; + int _remoteSessionGeneration = 0; final GetSessionID getSessionID; + final ReadRemoteDirectory _readRemoteDirectory; SessionID get sessionId => getSessionID(); - FileFetcher(this.getSessionID); + FileFetcher(this.getSessionID, {ReadRemoteDirectory? readRemoteDirectory}) + : _readRemoteDirectory = readRemoteDirectory ?? + ((sessionId, path, includeHidden) => bind.sessionReadRemoteDir( + sessionId: sessionId, + path: path, + includeHidden: includeHidden)); + + bool hasPendingRemoteRead(String path) => _remoteReadTasks.containsKey(path); + + bool isLikelyRemoteHomeResponse(String path) => + _remoteReadTasks.isEmpty || + (_remoteReadTasks.length == 1 && + hasPendingRemoteRead("") && + !hasPendingRemoteRead(path)); + + void beginRemoteSession() { + _remoteSessionGeneration++; + final pendingTasks = _remoteReadTasks.entries.toList(growable: false); + for (final entry in pendingTasks) { + final task = entry.value; + if (!_removeRemoteReadTask(entry.key, task)) continue; + task.completer.completeError(StateError(_kRemoteSessionChangedError)); + } + } + + _RemoteReadTask _registerRemoteReadTask(String path, bool includeHidden) { + if (hasPendingRemoteRead(path)) { + throw "Failed to registerReadTask, already have same read job"; + } + final task = _RemoteReadTask(includeHidden); + _remoteReadTasks[path] = task; + task.timer = Timer(_kRemoteReadDirTimeout, () { + if (!_removeRemoteReadTask(path, task)) return; + task.completer.completeError("Failed to read dir, timeout"); + }); + return task; + } + + bool _removeRemoteReadTask(String path, _RemoteReadTask task) { + if (!identical(_remoteReadTasks[path], task)) return false; + _remoteReadTasks.remove(path); + task.timer.cancel(); + task.released.complete(); + return true; + } + + bool _completeRemoteReadTask(String path, FileDirectory directory) { + final task = _remoteReadTasks[path]; + if (task == null || !_removeRemoteReadTask(path, task)) return false; + task.completer.complete(directory); + return true; + } Future> registerReadEmptyDirsTask( bool isLocal, String path) { @@ -1391,23 +1476,6 @@ class FileFetcher { return c.future; } - Future registerReadTask(bool isLocal, String path) { - // final jobs = isLocal?localJobs:remoteJobs; // maybe we will use read local dir async later - final tasks = remoteTasks; // bypass now - if (tasks.containsKey(path)) { - throw "Failed to registerReadTask, already have same read job"; - } - final c = Completer(); - tasks[path] = c; - - Timer(Duration(seconds: 2), () { - tasks.remove(path); - if (c.isCompleted) return; - c.completeError("Failed to read dir, timeout"); - }); - return c.future; - } - Future registerReadRecursiveTask(int actID) { final tasks = readRecursiveTasks; if (tasks.containsKey(actID)) { @@ -1445,27 +1513,37 @@ class FileFetcher { tryCompleteTask(String? msg, String? isLocalStr) { if (msg == null || isLocalStr == null) return; - late final Map> tasks; try { final fd = FileDirectory.fromJson(jsonDecode(msg)); if (fd.id > 0) { // fd.id > 0 is result for read recursive - // to-do later,will be better if every fetch use ID,so that there will only one task map for read and recursive read - tasks = readRecursiveTasks; - final completer = tasks.remove(fd.id); - completer?.complete(fd); - } else if (fd.path.isNotEmpty) { - // result for normal read dir - // final jobs = isLocal?localJobs:remoteJobs; // maybe we will use read local dir async later - tasks = remoteTasks; // bypass now - final completer = tasks.remove(fd.path); + final completer = readRecursiveTasks.remove(fd.id); completer?.complete(fd); + return; + } + if (isLocalStr == "false" && fd.path.isNotEmpty) { + if (_completeRemoteReadTask(fd.path, fd)) { + return; + } + // A Home request uses an empty path but returns its resolved path. + if (isLikelyRemoteHomeResponse(fd.path)) { + _completeRemoteReadTask("", fd); + } } } catch (e) { debugPrint("tryCompleteJob err: $e"); } } + bool tryCompleteRemoteTaskWithError(String error) { + if (_remoteReadTasks.length != 1) return false; + final entry = _remoteReadTasks.entries.single; + final task = entry.value; + if (!_removeRemoteReadTask(entry.key, task)) return false; + task.completer.completeError(error); + return true; + } + // Complete a pending recursive read task with an error. // See FileModel.handleJobError() for why this is necessary. void tryCompleteRecursiveTaskWithError(int id, String error) { @@ -1506,9 +1584,26 @@ class FileFetcher { final fd = FileDirectory.fromJson(jsonDecode(res)); return fd; } else { - await bind.sessionReadRemoteDir( - sessionId: sessionId, path: path, includeHidden: showHidden); - return registerReadTask(isLocal, path); + final remoteSessionGeneration = _remoteSessionGeneration; + final pendingTask = _remoteReadTasks[path]; + if (pendingTask != null) { + if (pendingTask.includeHidden == showHidden) { + return pendingTask.completer.future; + } + await pendingTask.released.future; + if (remoteSessionGeneration != _remoteSessionGeneration) { + throw StateError(_kRemoteSessionChangedError); + } + return fetchDirectory(path, isLocal, showHidden); + } + final task = _registerRemoteReadTask(path, showHidden); + unawaited(Future.sync( + () => _readRemoteDirectory(sessionId, path, showHidden)) + .catchError((Object error, StackTrace stackTrace) { + if (!_removeRemoteReadTask(path, task)) return; + task.completer.completeError(error, stackTrace); + })); + return task.completer.future; } } catch (e) { return Future.error(e); diff --git a/flutter/test/file_model_test.dart b/flutter/test/file_model_test.dart new file mode 100644 index 000000000..9455f2cab --- /dev/null +++ b/flutter/test/file_model_test.dart @@ -0,0 +1,281 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_hbb/models/file_model.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:uuid/uuid.dart'; + +final _sessionId = UuidValue('00000000-0000-0000-0000-000000000000'); + +class _FakeFFI implements FFI { + @override + String id = 'test-peer'; + @override + UuidValue get sessionId => _sessionId; + @override + late final FfiModel ffiModel = FfiModel(WeakReference(this)); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +FileController _createController(FileFetcher fileFetcher) { + final ffi = _FakeFFI(); + return FileController( + isLocal: false, + getSessionID: () => _sessionId, + rootState: WeakReference(ffi), + jobController: JobController(() => _sessionId, () => null), + fileFetcher: fileFetcher, + getOtherSideDirectoryData: () => + DirectoryData(FileDirectory(), DirectoryOptions()), + ); +} + +FileDirectory _directory(String path) => FileDirectory()..path = path; + +String _directoryJson(String path) => jsonEncode({ + 'id': 0, + 'path': path, + 'entries': [], + }); + +class _SentRead { + final String path; + final bool includeHidden; + + const _SentRead(this.path, this.includeHidden); +} + +void main() { + test('a fast remote response is matched after registration', () async { + late final FileFetcher fileFetcher; + fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, path, __) { + fileFetcher.tryCompleteTask(_directoryJson(path), 'false'); + return Future.value(); + }, + ); + final directory = await fileFetcher.fetchDirectory('/fast', false, false); + expect(directory.path, '/fast'); + }); + + test('a send failure fails and removes its registered task', () async { + final failure = StateError('send failed'); + final fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, __, ___) => Future.error(failure), + ); + await expectLater( + fileFetcher.fetchDirectory('/failed', false, false), + throwsA(same(failure)), + ); + expect(fileFetcher.hasPendingRemoteRead('/failed'), isFalse); + }); + + test('a resolved Home path completes the sole empty-path request', () async { + final sent = <_SentRead>[]; + final fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, path, includeHidden) async { + sent.add(_SentRead(path, includeHidden)); + }, + ); + final controller = _createController(fileFetcher); + controller.directory.value = _directory('/initial'); + + final home = controller.openDirectory(''); + await Future.delayed(Duration.zero); + final response = _directoryJson('/home/user'); + controller.initDirAndHome({'value': response}); + expect(controller.homePath, '/home/user'); + expect(controller.directory.value.path, '/initial'); + fileFetcher.tryCompleteTask(response, 'false'); + expect(await home, isTrue); + expect(controller.directory.value.path, '/home/user'); + expect(sent.single.path, isEmpty); + }); + + test('an automatic response initializes Home without a pending request', () { + final controller = _createController(FileFetcher(() => _sessionId)); + + controller.initDirAndHome({'value': _directoryJson('/home/user')}); + + expect(controller.homePath, '/home/user'); + expect(controller.directory.value.path, '/home/user'); + }); + + test('an exact path response is not taken by a pending Home request', + () async { + final sent = <_SentRead>[]; + final fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, path, includeHidden) async { + sent.add(_SentRead(path, includeHidden)); + }, + ); + final home = fileFetcher.fetchDirectory('', false, false); + final regular = fileFetcher.fetchDirectory('/regular', false, false); + await Future.delayed(Duration.zero); + var homeCompleted = false; + home.then((_) => homeCompleted = true); + + fileFetcher.tryCompleteTask(_directoryJson('/unmatched'), 'false'); + await Future.delayed(Duration.zero); + expect(homeCompleted, isFalse); + + fileFetcher.tryCompleteTask(_directoryJson('/regular'), 'false'); + expect((await regular).path, '/regular'); + await Future.delayed(Duration.zero); + expect(homeCompleted, isFalse); + + fileFetcher.tryCompleteTask(_directoryJson('/home/user'), 'false'); + expect((await home).path, '/home/user'); + expect(sent.map((request) => request.path), ['', '/regular']); + }); + + test('a read error completes the sole pending request', () async { + final fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, __, ___) async {}, + ); + final request = fileFetcher.fetchDirectory('/denied', false, false); + await Future.delayed(Duration.zero); + final expectation = expectLater(request, throwsA('permission denied')); + + fileFetcher.tryCompleteRemoteTaskWithError('permission denied'); + + await expectation; + expect(fileFetcher.hasPendingRemoteRead('/denied'), isFalse); + }); + + test('same-path requests share the pending read', () async { + final sent = <_SentRead>[]; + late final FileFetcher fileFetcher; + fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, path, includeHidden) async { + sent.add(_SentRead(path, includeHidden)); + }, + ); + final controller = _createController(fileFetcher); + controller.directory.value = _directory('/initial'); + + final first = controller.openDirectory('/same'); + final waiting = controller.openDirectory('/same'); + + await Future.delayed(Duration.zero); + expect(sent.map((request) => request.path), ['/same']); + fileFetcher.tryCompleteTask(_directoryJson('/same'), 'false'); + expect(await first, isTrue); + await Future.delayed(Duration.zero); + + expect(sent.map((request) => request.path), ['/same']); + expect(await waiting, isTrue); + expect(controller.directory.value.path, '/same'); + }); + + test('same-path requests with different hidden options are serialized', + () async { + final sent = <_SentRead>[]; + final fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, path, includeHidden) async { + sent.add(_SentRead(path, includeHidden)); + }, + ); + + final first = fileFetcher.fetchDirectory('/same', false, false); + final second = fileFetcher.fetchDirectory('/same', false, true); + + await Future.delayed(Duration.zero); + expect(sent.map((request) => request.includeHidden), [false]); + + fileFetcher.tryCompleteTask(_directoryJson('/same'), 'false'); + expect((await first).path, '/same'); + await Future.delayed(Duration.zero); + expect(sent.map((request) => request.includeHidden), [false, true]); + + fileFetcher.tryCompleteTask(_directoryJson('/same'), 'false'); + expect((await second).path, '/same'); + }); + + test('session invalidation cancels active and waiting reads', () async { + final sent = <_SentRead>[]; + final fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, path, includeHidden) async { + sent.add(_SentRead(path, includeHidden)); + }, + ); + final first = fileFetcher.fetchDirectory('/same', false, false); + final waiting = fileFetcher.fetchDirectory('/same', false, true); + await Future.delayed(Duration.zero); + final firstError = expectLater(first, throwsA(isA())); + final waitingError = expectLater(waiting, throwsA(isA())); + + fileFetcher.beginRemoteSession(); + + await firstError; + await Future.delayed(Duration.zero); + expect(sent.map((request) => request.includeHidden), [false]); + await waitingError; + expect(fileFetcher.hasPendingRemoteRead('/same'), isFalse); + final replacement = fileFetcher.fetchDirectory('/same', false, true); + await Future.delayed(Duration.zero); + expect(sent.map((request) => request.includeHidden), [false, true]); + fileFetcher.tryCompleteTask(_directoryJson('/same'), 'false'); + expect((await replacement).path, '/same'); + }); + + test('a late dispatch failure cannot remove a replacement task', () async { + final dispatches = >[]; + final fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, __, ___) { + final dispatch = Completer(); + dispatches.add(dispatch); + return dispatch.future; + }, + ); + final first = fileFetcher.fetchDirectory('/same', false, false); + await Future.delayed(Duration.zero); + final firstError = expectLater(first, throwsA(isA())); + fileFetcher.beginRemoteSession(); + await firstError; + + final replacement = fileFetcher.fetchDirectory('/same', false, false); + await Future.delayed(Duration.zero); + expect(dispatches, hasLength(2)); + dispatches.first.completeError(StateError('late dispatch failure')); + await Future.delayed(Duration.zero); + + expect(fileFetcher.hasPendingRemoteRead('/same'), isTrue); + fileFetcher.tryCompleteTask(_directoryJson('/same'), 'false'); + expect((await replacement).path, '/same'); + dispatches.last.complete(); + await Future.delayed(Duration.zero); + }); + + test('navigation ignores stale directory responses', () async { + final fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, __, ___) async {}, + ); + final controller = _createController(fileFetcher); + controller.directory.value = _directory('/initial'); + + final stale = controller.openDirectory('/stale'); + final latest = controller.openDirectory('/latest'); + await Future.delayed(Duration.zero); + + fileFetcher.tryCompleteTask(_directoryJson('/latest'), 'false'); + expect(await latest, isTrue); + fileFetcher.tryCompleteTask(_directoryJson('/stale'), 'false'); + expect(await stale, isTrue); + + expect(controller.directory.value.path, '/latest'); + }); +} diff --git a/src/ui_cm_interface.rs b/src/ui_cm_interface.rs index 1474ce093..5e13ef82b 100644 --- a/src/ui_cm_interface.rs +++ b/src/ui_cm_interface.rs @@ -1546,13 +1546,19 @@ async fn read_dir(dir: &str, include_hidden: bool, tx: &UnboundedSender) { fs::get_path(dir) } }; - if let Ok(Ok(fd)) = spawn_blocking(move || fs::read_dir(&path, include_hidden)).await { - let mut msg_out = Message::new(); - let mut file_response = FileResponse::new(); - file_response.set_dir(fd); - msg_out.set_file_response(file_response); - send_raw(msg_out, tx); - } + let result = spawn_blocking(move || fs::read_dir(&path, include_hidden)).await; + let msg_out = match result { + Ok(Ok(fd)) => { + let mut msg_out = Message::new(); + let mut file_response = FileResponse::new(); + file_response.set_dir(fd); + msg_out.set_file_response(file_response); + msg_out + } + Ok(Err(err)) => fs::new_error(0, err, -1), + Err(err) => fs::new_error(0, err, -1), + }; + send_raw(msg_out, tx); } #[cfg(not(any(target_os = "ios")))] @@ -1750,7 +1756,7 @@ mod tests { #[test] #[cfg(not(any(target_os = "ios")))] - fn read_dir_success() { + fn read_dir_reports_success_and_error() { let rt = Runtime::new().unwrap(); rt.block_on(async { let (tx, mut rx) = unbounded_channel(); @@ -1773,6 +1779,18 @@ mod tests { _ => panic!("unexpected data"), } let _ = fs::remove_dir_all(&dir); + + super::read_dir(&dir.to_string_lossy(), false, &tx).await; + + match rx.recv().await.unwrap() { + Data::RawMessage(bytes) => { + let mut msg = Message::new(); + msg.merge_from_bytes(&bytes).unwrap(); + assert_eq!(msg.file_response().error().id, 0); + assert!(!msg.file_response().error().error.is_empty()); + } + _ => panic!("unexpected data"), + } }); } From 1fe451c2e819e95cb09a00b36b3efdc132df3cb7 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:42:09 +0800 Subject: [PATCH 64/72] chore(flutter): bump desktop_multi_window for show recovery (#15959) Pick up rustdesk-org/rustdesk_desktop_multi_window#37, which re-arms the existing bounded redraw timer whenever a secondary window is shown, including when its first frame was generated while hidden but not presented. This may perform one delayed child refresh on each show. It intentionally does not add a presentation-complete flag: Flutter reports frame generation rather than successful presentation, so recording success after a synthetic refresh could suppress later self-recovery without a reliable success signal. --- flutter/pubspec.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 5aae2440f..1a90336ba 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -340,7 +340,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: 533883bcb0ffe91a9afdb13b8bac9b14b3e054ba + resolved-ref: 8b774a66671cbb9bcb2631af6ac28f9bdd469ce3 url: "https://github.com/rustdesk-org/rustdesk_desktop_multi_window" source: git version: "0.1.0" From 03a7fc5992069cc5bc9f7c36b872483dddf4f472 Mon Sep 17 00:00:00 2001 From: fufesou Date: Thu, 27 Aug 2026 16:33:58 +0800 Subject: [PATCH 65/72] fix(flutter): align terminal shortcuts with platform conventions (#15970) * fix(flutter): align terminal shortcuts with platform conventions Signed-off-by: fufesou * fix(flutter): handle Linux terminal paste with modifier locks Detect platform-specific paste shortcuts so Ctrl+Shift+V bypasses virtual Ctrl/Alt modifiers on Linux. Add regression coverage. Signed-off-by: fufesou --------- Signed-off-by: fufesou --- flutter/lib/mobile/pages/terminal_page.dart | 10 ++- flutter/lib/models/input_modifier_utils.dart | 21 ++++-- .../lib/models/terminal_copy_shortcut.dart | 67 +++++++++++++------ flutter/test/input_modifier_utils_test.dart | 39 +++++++++++ 4 files changed, 110 insertions(+), 27 deletions(-) diff --git a/flutter/lib/mobile/pages/terminal_page.dart b/flutter/lib/mobile/pages/terminal_page.dart index 800b0f8f4..7a8c03ebb 100644 --- a/flutter/lib/mobile/pages/terminal_page.dart +++ b/flutter/lib/mobile/pages/terminal_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -8,6 +9,7 @@ import 'package:flutter_hbb/common/widgets/dialog.dart'; import 'package:flutter_hbb/models/input_modifier_utils.dart'; import 'package:flutter_hbb/models/model.dart'; import 'package:flutter_hbb/models/platform_model.dart'; +import 'package:flutter_hbb/models/terminal_copy_shortcut.dart'; import 'package:flutter_hbb/models/terminal_model.dart'; import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart'; import 'package:flutter_hbb/web/dummy.dart' @@ -190,6 +192,7 @@ class _TerminalPageState extends State KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) { final hardwareKeyboard = HardwareKeyboard.instance; final shouldPaste = shouldHandleTerminalPasteShortcut( + platform: defaultTargetPlatform, logicalKey: event.logicalKey, isKeyDown: event is KeyDownEvent, isKeyRepeat: event is KeyRepeatEvent, @@ -244,7 +247,12 @@ class _TerminalPageState extends State // // Android works fine without this workaround. deleteDetection: isIOS, - onKeyEvent: _handleTerminalKeyEvent, + shortcuts: platformTerminalShortcuts(), + onKeyEvent: terminalCopyHandler( + _terminalModel.terminal, + _terminalModel.terminalController, + fallback: _handleTerminalKeyEvent, + ), padding: _calculatePadding(heightPx), onSecondaryTapDown: (details, offset) async { final selection = _terminalModel.terminalController.selection; diff --git a/flutter/lib/models/input_modifier_utils.dart b/flutter/lib/models/input_modifier_utils.dart index 9b8aae881..093e65776 100644 --- a/flutter/lib/models/input_modifier_utils.dart +++ b/flutter/lib/models/input_modifier_utils.dart @@ -117,10 +117,11 @@ String prepareTerminalInputPayload( /// Returns true when a hardware paste shortcut must bypass keyboard modifiers. /// -/// xterm already handles hardware Ctrl/Cmd+V correctly in the common case. Only -/// intercept while a virtual Ctrl/Alt lock is active, because xterm can emit a -/// one-character paste as normal text when bracketed paste mode is disabled. +/// xterm already handles each platform's paste shortcut in the common case. +/// Only intercept while a virtual Ctrl/Alt lock is active, because xterm can +/// emit a one-character paste as normal text when bracketed paste mode is off. bool shouldHandleTerminalPasteShortcut({ + required TargetPlatform platform, required LogicalKeyboardKey logicalKey, required bool isKeyDown, required bool isKeyRepeat, @@ -133,8 +134,18 @@ bool shouldHandleTerminalPasteShortcut({ if (!modifierLockActive) return false; if (!isKeyDown && !isKeyRepeat) return false; if (logicalKey != LogicalKeyboardKey.keyV) return false; - if (altPressed || shiftPressed) return false; - return controlPressed != metaPressed; + if (altPressed) return false; + switch (platform) { + case TargetPlatform.linux: + return controlPressed && !metaPressed && shiftPressed; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return !controlPressed && metaPressed && !shiftPressed; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.windows: + return controlPressed && !metaPressed && !shiftPressed; + } } /// Returns true when collapsing Row3 should also clear hidden modifier state. diff --git a/flutter/lib/models/terminal_copy_shortcut.dart b/flutter/lib/models/terminal_copy_shortcut.dart index a526c16b4..242586e6b 100644 --- a/flutter/lib/models/terminal_copy_shortcut.dart +++ b/flutter/lib/models/terminal_copy_shortcut.dart @@ -20,43 +20,68 @@ Future writeTerminalClipboard(String text) async { } Map? platformTerminalShortcuts() { - if (defaultTargetPlatform != TargetPlatform.linux) return null; + final platform = defaultTargetPlatform; + if (platform == TargetPlatform.linux) { + return { + for (final entry in defaultTerminalShortcuts.entries) + if (!_isControlShortcut(entry.key, LogicalKeyboardKey.keyV)) + entry.key: entry.value, + _controlShiftVPasteShortcut: + const PasteTextIntent(SelectionChangedCause.keyboard), + }; + } + if (platform != TargetPlatform.windows && + platform != TargetPlatform.android) { + return null; + } return { for (final entry in defaultTerminalShortcuts.entries) - if (!_isControlVShortcut(entry.key)) entry.key: entry.value, - _controlShiftVPasteShortcut: - const PasteTextIntent(SelectionChangedCause.keyboard), + if (!_isControlShortcut( + entry.key, + LogicalKeyboardKey.keyC, + shift: true, + )) + entry.key: entry.value, }; } -bool _isControlVShortcut(ShortcutActivator shortcut) => +bool _isControlShortcut( + ShortcutActivator shortcut, + LogicalKeyboardKey key, { + bool shift = false, +}) => shortcut is SingleActivator && - shortcut.trigger == LogicalKeyboardKey.keyV && + shortcut.trigger == key && shortcut.control && - !shortcut.shift && + shortcut.shift == shift && !shortcut.alt && !shortcut.meta; FocusOnKeyEventCallback terminalCopyHandler( Terminal terminal, - TerminalController controller, -) => - (_, event) { - if (!_isWindowsCopyShortcut(event)) return KeyEventResult.ignored; - final selection = controller.selection; - if (selection == null || selection.isCollapsed) { - return KeyEventResult.ignored; + TerminalController controller, { + FocusOnKeyEventCallback? fallback, +}) => + (focusNode, event) { + if (_isSelectionCopyShortcut(event)) { + final selection = controller.selection; + if (selection != null && !selection.isCollapsed) { + if (event is KeyDownEvent) { + final text = terminal.buffer.getText(selection); + unawaited(writeTerminalClipboard(text)); + } + return KeyEventResult.handled; + } } - if (event is KeyDownEvent) { - final text = terminal.buffer.getText(selection); - unawaited(writeTerminalClipboard(text)); - } - return KeyEventResult.handled; + return fallback?.call(focusNode, event) ?? KeyEventResult.ignored; }; -bool _isWindowsCopyShortcut(KeyEvent event) { +bool _isSelectionCopyShortcut(KeyEvent event) { final keyboard = HardwareKeyboard.instance; - return defaultTargetPlatform == TargetPlatform.windows && + final platform = defaultTargetPlatform; + final usesControlCopy = + platform == TargetPlatform.windows || platform == TargetPlatform.android; + return usesControlCopy && (event is KeyDownEvent || event is KeyRepeatEvent) && event.logicalKey == LogicalKeyboardKey.keyC && keyboard.isControlPressed && diff --git a/flutter/test/input_modifier_utils_test.dart b/flutter/test/input_modifier_utils_test.dart index 5a1a76a77..bebf8d8af 100644 --- a/flutter/test/input_modifier_utils_test.dart +++ b/flutter/test/input_modifier_utils_test.dart @@ -342,11 +342,43 @@ void main() { }); group('shouldHandleTerminalPasteShortcut', () { + test('handles only Ctrl+Shift+V on Linux with a virtual lock', () { + expect( + shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.linux, + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: true, + modifierLockActive: true, + ), + isTrue, + ); + expect( + shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.linux, + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isFalse, + ); + }); + test( 'keeps default xterm paste behavior when virtual modifiers are inactive', () { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: true, isKeyRepeat: false, @@ -364,6 +396,7 @@ void main() { () { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: true, isKeyRepeat: false, @@ -377,6 +410,7 @@ void main() { ); expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.macOS, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: true, isKeyRepeat: false, @@ -393,6 +427,7 @@ void main() { test('handles paste shortcut repeats while a virtual lock is active', () { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: false, isKeyRepeat: true, @@ -409,6 +444,7 @@ void main() { test('ignores key-up and unmodified V events', () { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: false, isKeyRepeat: false, @@ -422,6 +458,7 @@ void main() { ); expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: true, isKeyRepeat: false, @@ -444,6 +481,7 @@ void main() { ]) { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: true, isKeyRepeat: false, @@ -461,6 +499,7 @@ void main() { test('ignores non-V key events', () { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyC, isKeyDown: true, isKeyRepeat: false, From d4b06a6c5ca36596970d7c8f087687be3c3772f7 Mon Sep 17 00:00:00 2001 From: Michael Clark <104532890+michaeljclarkk@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:29:51 +1000 Subject: [PATCH 66/72] fix: android: replace all-files access with scoped storage (#15602) * fix: android: replace all-files access with scoped storage + system picker Remove MANAGE_EXTERNAL_STORAGE, READ_EXTERNAL_STORAGE, and WRITE_EXTERNAL_STORAGE from the Android manifest. Remove requestLegacyExternalStorage. Replace broad external storage with app-scoped external storage for the file-transfer workspace. File import uses the system file_picker. File export uses Android's SAF ACTION_CREATE_DOCUMENT with path validation that restricts export sources to app-owned directories. Remove the external_path dependency. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: refine file import feedback Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: use SAF for file imports Replace file_picker imports with Android's Storage Access Framework to avoid legacy storage permissions, stale cached files, and duplicate staging of large imports. Stream selected documents into app-scoped storage with failure-safe replacement, keep exports restricted to validated app storage roots, use filesDir for the internal fallback workspace, and remove legacy permissions contributed during manifest merging. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: keep file imports in the selected directory Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: reset projection and constrain file workspace Release capture resources when media projection is revoked externally. Keep Android local file navigation within the app-scoped workspace. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: handle scoped storage start-up regressions. Allow zero digits in POSIX filenames by rejecting NUL explicitly, and initialise the app-specific home directory before the Android service starts the native server. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: update content resolver mode to use 'wt' instead of 'w' to prevent trailing bytes from old document whilst reporting sucess Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android, enforce file workspace boundary on the server, and unblock the ui thread. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: validate rename destinations against the app workspace bound file-operation paths. report rename failures, general import failures, and unregister / reregister projection when its onStop callback fires. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: reconnect was refreshing the directory with net entry instances, while selected items retained the old instances, it was reporting a selected item, but checkbox statue used object identity, and appeared unchecked. Fixed by reconciling by path and entry type before replacing the directory snapshot, rebinding valid selections, and dropping missing ones. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: (android) add SAF folder import and multi item export - import directories using ACTION_OPEN_DOCUMENT_TREE. Export multiple files, logs, and screen recordings via export buttons, add localisation keys for new actions Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix(android): harden scoped storage file handling - create new SAF documents instead of overwriting export sources - reject empty peer paths except for home directory reads - report directory backup restore and cleanup failures - resolve log export paths from the configured app name Signed-off-by: fufesou * fix(android): harden scoped-storage file operations - snapshot directory exports before writing to the destination - query document provider metadata off the main thread - reject invalid remote directories without read timeouts Signed-off-by: fufesou * fix(android): handle SAF directory name collisions - reject dot-segment folder names during import - fail imports with duplicate document display names - only reuse matching directories during export Signed-off-by: fufesou * fix(android): handle SAF folder import collisions Reject filesystem-equivalent destination names and avoid showing a failure when folder overwrite is skipped. Signed-off-by: fufesou --------- Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> Signed-off-by: fufesou Co-authored-by: fufesou --- .../android/app/src/main/AndroidManifest.xml | 9 +- .../com/carriez/flutter_hbb/MainActivity.kt | 585 ++++++++++++++++++ .../com/carriez/flutter_hbb/MainService.kt | 30 +- .../kotlin/com/carriez/flutter_hbb/common.kt | 10 + flutter/android/app/src/main/kotlin/ffi.kt | 2 +- flutter/lib/common.dart | 14 - flutter/lib/consts.dart | 7 +- .../lib/mobile/pages/file_manager_page.dart | 232 +++++++ flutter/lib/mobile/pages/server_page.dart | 6 - flutter/lib/models/file_model.dart | 39 +- flutter/lib/models/model.dart | 33 + flutter/lib/models/native_model.dart | 13 +- flutter/lib/models/server_model.dart | 26 +- flutter/lib/models/web_model.dart | 5 + flutter/pubspec.lock | 8 - flutter/pubspec.yaml | 1 - src/common.rs | 55 ++ src/flutter_ffi.rs | 4 + src/lang/ar.rs | 3 + src/lang/be.rs | 3 + src/lang/bg.rs | 3 + src/lang/ca.rs | 3 + src/lang/cn.rs | 3 + src/lang/cs.rs | 3 + src/lang/da.rs | 3 + src/lang/de.rs | 3 + src/lang/el.rs | 3 + src/lang/eo.rs | 3 + src/lang/es.rs | 3 + src/lang/et.rs | 3 + src/lang/eu.rs | 3 + src/lang/fa.rs | 3 + src/lang/fi.rs | 3 + src/lang/fr.rs | 3 + src/lang/ge.rs | 3 + src/lang/gu.rs | 3 + src/lang/he.rs | 3 + src/lang/hi.rs | 3 + src/lang/hr.rs | 3 + src/lang/hu.rs | 3 + src/lang/id.rs | 3 + src/lang/it.rs | 3 + src/lang/ja.rs | 3 + src/lang/ko.rs | 3 + src/lang/kz.rs | 3 + src/lang/lt.rs | 3 + src/lang/lv.rs | 3 + src/lang/ml.rs | 3 + src/lang/nb.rs | 3 + src/lang/nl.rs | 3 + src/lang/pl.rs | 3 + src/lang/pt_PT.rs | 3 + src/lang/ptbr.rs | 3 + src/lang/ro.rs | 3 + src/lang/ru.rs | 3 + src/lang/sc.rs | 3 + src/lang/sk.rs | 3 + src/lang/sl.rs | 3 + src/lang/sq.rs | 3 + src/lang/sr.rs | 3 + src/lang/sv.rs | 3 + src/lang/ta.rs | 3 + src/lang/template.rs | 3 + src/lang/th.rs | 3 + src/lang/tr.rs | 3 + src/lang/tw.rs | 3 + src/lang/uk.rs | 3 + src/lang/vi.rs | 3 + src/server/connection.rs | 91 ++- src/ui_cm_interface.rs | 55 ++ 70 files changed, 1304 insertions(+), 71 deletions(-) diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index f4788af4c..2d9616a6c 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -1,15 +1,17 @@ - + + + - - + @@ -26,7 +28,6 @@ android:name=".MainApplication" android:icon="@mipmap/ic_launcher" android:label="RustDesk" - android:requestLegacyExternalStorage="true" android:roundIcon="@mipmap/ic_launcher" android:supportsRtl="true"> diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt index 7274085fd..02cec3c25 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt @@ -9,6 +9,7 @@ package com.carriez.flutter_hbb import ffi.FFI +import android.app.Activity import android.content.ComponentName import android.content.Context import android.content.Intent @@ -24,6 +25,10 @@ import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar import android.media.MediaCodecList import android.media.MediaFormat +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap import android.util.DisplayMetrics import androidx.annotation.RequiresApi import org.json.JSONArray @@ -33,6 +38,9 @@ import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import kotlin.concurrent.thread +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream class MainActivity : FlutterActivity() { @@ -46,6 +54,23 @@ class MainActivity : FlutterActivity() { private val channelTag = "mChannel" private val logTag = "mMainActivity" private var mainService: MainService? = null + private sealed class PendingPicker { + data class ImportFiles(val result: MethodChannel.Result) : PendingPicker() + data class ExportFile(val source: File, val result: MethodChannel.Result) : PendingPicker() + data class ImportDirectory(val result: MethodChannel.Result) : PendingPicker() + data class ExportFiles( + val sources: List, + val rejected: Int, + val result: MethodChannel.Result + ) : PendingPicker() + } + + private data class ExportSource( + val file: File, + val children: List? + ) + + private var pendingPicker: PendingPicker? = null private var isAudioStart = false private val audioRecordHandle = AudioRecordHandle(this, { false }, { isAudioStart }) @@ -91,6 +116,108 @@ class MainActivity : FlutterActivity() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) + if (requestCode == REQ_IMPORT_FILES) { + val pending = pendingPicker as? PendingPicker.ImportFiles ?: return + pendingPicker = null + if (resultCode != Activity.RESULT_OK || data == null) { + pending.result.success(emptyList>()) + return + } + + val uris = linkedSetOf() + data.data?.let { uris.add(it) } + data.clipData?.let { clipData -> + for (index in 0 until clipData.itemCount) { + uris.add(clipData.getItemAt(index).uri) + } + } + thread { + val files = uris.map { uri -> + mapOf( + "uri" to uri.toString(), + "name" to (displayName(uri) ?: uri.lastPathSegment.orEmpty()) + ) + } + runOnUiThread { pending.result.success(files) } + } + return + } + if (requestCode == REQ_EXPORT_FILE) { + val pending = pendingPicker as? PendingPicker.ExportFile ?: return + pendingPicker = null + val destination = data?.data + + if (resultCode != Activity.RESULT_OK || destination == null) { + pending.result.success(false) + return + } + + thread { + try { + FileInputStream(pending.source).use { input -> + contentResolver.openOutputStream(destination, "wt")?.use { output -> + input.copyTo(output) + } ?: throw IllegalStateException("Unable to open the selected destination") + } + runOnUiThread { pending.result.success(true) } + } catch (e: Exception) { + Log.e(logTag, "Failed to export file", e) + runOnUiThread { + pending.result.error("export_failed", e.message, null) + } + } + } + return + } + if (requestCode == REQ_IMPORT_DIRECTORY) { + val pending = pendingPicker as? PendingPicker.ImportDirectory ?: return + pendingPicker = null + val treeUri = data?.data + if (resultCode != Activity.RESULT_OK || treeUri == null) { + pending.result.success(null) + return + } + thread { + val selected = mapOf( + "uri" to treeUri.toString(), + "name" to (treeDisplayName(treeUri) ?: "Imported") + ) + runOnUiThread { pending.result.success(selected) } + } + return + } + if (requestCode == REQ_EXPORT_FILES) { + val pending = pendingPicker as? PendingPicker.ExportFiles ?: return + pendingPicker = null + val treeUri = data?.data + if (resultCode != Activity.RESULT_OK || treeUri == null) { + pending.result.success(null) + return + } + thread { + var exported = 0 + var failed = pending.rejected + var processed = 0 + try { + val sources = pending.sources.map { snapshotExportSource(it) } + val rootDocId = DocumentsContract.getTreeDocumentId(treeUri) + sources.forEach { source -> + val ok = source?.let { + copyExportSourceToTree(treeUri, rootDocId, it) + } ?: false + if (ok) exported++ else failed++ + processed++ + } + } catch (e: Exception) { + Log.e(logTag, "Failed to export selected files", e) + failed += pending.sources.size - processed + } + runOnUiThread { + pending.result.success(mapOf("exported" to exported, "failed" to failed)) + } + } + return + } if (requestCode == REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION && resultCode == RES_FAILED) { flutterMethodChannel?.invokeMethod("on_media_projection_canceled", null) } @@ -267,6 +394,242 @@ class MainActivity : FlutterActivity() { result.success(false) } } + PICK_IMPORT_FILES -> { + if (pendingPicker != null) { + result.error("picker_in_progress", "Another document picker is already open", null) + } else { + pendingPicker = PendingPicker.ImportFiles(result) + try { + startActivityForResult( + Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + }, + REQ_IMPORT_FILES + ) + } catch (e: Exception) { + pendingPicker = null + result.error("picker_unavailable", e.message, null) + } + } + } + IMPORT_FILE -> { + val arguments = call.arguments as? Map<*, *> + val uri = (arguments?.get("uri") as? String)?.let { + runCatching { Uri.parse(it) }.getOrNull() + } + val path = arguments?.get("path") as? String + val overwrite = arguments?.get("overwrite") as? Boolean ?: false + val destination = path?.let { canonicalAppScopedFile(it) } + + if (uri?.scheme != "content") { + result.error("invalid_uri", "The selected document URI is invalid", null) + } else if (destination == null || + destination.isDirectory || + destination.parentFile?.isDirectory != true) { + result.error("invalid_destination", "The destination is outside app-scoped storage", null) + } else { + thread { + var temporary: File? = null + var reservedDestination = false + var errorCode = "import_failed" + try { + val temporaryFile = File.createTempFile( + ".rustdesk-import-", + ".tmp", + destination.parentFile + ) + temporary = temporaryFile + contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(temporaryFile).use { output -> + input.copyTo(output) + } + } ?: throw IllegalStateException("Unable to open the selected document") + if (!overwrite) { + reservedDestination = destination.createNewFile() + if (!reservedDestination) { + throw IllegalStateException("The destination already exists") + } + } + if (!temporaryFile.renameTo(destination)) { + if (reservedDestination) { + destination.delete() + } + errorCode = "rename_failed" + throw IllegalStateException("Unable to replace the destination") + } + runOnUiThread { result.success(true) } + } catch (e: Exception) { + Log.e(logTag, "Failed to import file", e) + runOnUiThread { + result.error(errorCode, e.message, null) + } + } finally { + temporary?.delete() + } + } + } + } + EXPORT_FILE -> { + val path = (call.arguments as? Map<*, *>)?.get("path") as? String + val source = path?.let { canonicalExportSource(it) } + + if (source?.isFile != true) { + result.error("invalid_source", "The file is outside app-scoped storage", null) + } else if (pendingPicker != null) { + result.error("picker_in_progress", "Another document picker is already open", null) + } else { + val mimeType = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(source.extension.lowercase()) + ?: "application/octet-stream" + pendingPicker = PendingPicker.ExportFile(source, result) + try { + startActivityForResult( + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = mimeType + putExtra(Intent.EXTRA_TITLE, source.name) + }, + REQ_EXPORT_FILE + ) + } catch (e: Exception) { + pendingPicker = null + result.error("picker_unavailable", e.message, null) + } + } + } + PICK_IMPORT_DIRECTORY -> { + if (pendingPicker != null) { + result.error("picker_in_progress", "Another document picker is already open", null) + } else { + pendingPicker = PendingPicker.ImportDirectory(result) + try { + startActivityForResult( + Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + putExtra(Intent.EXTRA_TITLE, "Select the folder to import") + }, + REQ_IMPORT_DIRECTORY + ) + } catch (e: Exception) { + pendingPicker = null + result.error("picker_unavailable", e.message, null) + } + } + } + IMPORT_DIRECTORY -> { + val arguments = call.arguments as? Map<*, *> + val uri = (arguments?.get("uri") as? String)?.let { + runCatching { Uri.parse(it) }.getOrNull() + } + val path = arguments?.get("path") as? String + val overwrite = arguments?.get("overwrite") as? Boolean ?: false + val destination = path?.let { canonicalAppScopedFile(it) } + + if (uri?.scheme != "content") { + result.error("invalid_uri", "The selected document URI is invalid", null) + } else if (destination == null || + destination.parentFile?.isDirectory != true || + (destination.exists() && !destination.isDirectory)) { + result.error("invalid_destination", "The destination is outside app-scoped storage", null) + } else { + thread { + var temporary: File? = null + var backup: File? = null + val ok = try { + val parent = destination.parentFile + ?: throw IllegalStateException("The destination has no parent") + temporary = File.createTempFile( + ".rustdesk-import-dir-", + ".tmp", + parent + ).also { + if (!it.delete() || !it.mkdir()) { + throw IllegalStateException("Unable to create a temporary folder") + } + } + if (!copyDocumentTreeToFile(uri, temporary!!)) { + throw IllegalStateException("Unable to read all folder contents") + } + if (destination.exists()) { + if (!overwrite) { + throw IllegalStateException("The destination already exists") + } + val backupFile = File.createTempFile( + ".rustdesk-import-backup-", + ".tmp", + parent + ) + if (!backupFile.delete()) { + throw IllegalStateException("Unable to prepare the destination backup") + } + backup = backupFile + if (!destination.renameTo(backupFile)) { + throw IllegalStateException("Unable to replace the destination") + } + } + if (!temporary!!.renameTo(destination)) { + val destinationBackup = backup + if (destinationBackup != null && + !destinationBackup.renameTo(destination) + ) { + throw IllegalStateException( + "Unable to move the imported folder and restore " + + "the destination from $destinationBackup" + ) + } + throw IllegalStateException("Unable to move the imported folder") + } + temporary = null + val destinationBackup = backup + if (destinationBackup != null && + !destinationBackup.deleteRecursively() + ) { + throw IllegalStateException( + "Unable to remove the destination backup: $destinationBackup" + ) + } + backup = null + true + } catch (e: Exception) { + Log.e(logTag, "Failed to import directory", e) + false + } finally { + temporary?.deleteRecursively() + } + runOnUiThread { result.success(ok) } + } + } + } + EXPORT_FILES -> { + val paths = (call.arguments as? Map<*, *>)?.get("paths") as? List<*> + if (paths.isNullOrEmpty()) { + result.error("invalid_source", "The selected files are outside app-scoped storage", null) + } else { + val sources = paths.mapNotNull { + (it as? String)?.let(::canonicalExportSource) + } + val rejected = paths.size - sources.size + if (sources.isEmpty()) { + result.success(mapOf("exported" to 0, "failed" to rejected)) + } else if (pendingPicker != null) { + result.error("picker_in_progress", "Another document picker is already open", null) + } else { + pendingPicker = PendingPicker.ExportFiles(sources, rejected, result) + try { + startActivityForResult( + Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + putExtra(Intent.EXTRA_TITLE, "Select the destination folder") + }, + REQ_EXPORT_FILES + ) + } catch (e: Exception) { + pendingPicker = null + result.error("picker_unavailable", e.message, null) + } + } + } + } GET_VALUE -> { if (call.arguments is String) { if (call.arguments == KEY_IS_SUPPORT_VOICE_CALL) { @@ -291,6 +654,228 @@ class MainActivity : FlutterActivity() { } } + private fun canonicalAppScopedFile(path: String): File? { + val file = runCatching { File(path).canonicalFile }.getOrNull() ?: return null + val allowedRoots = listOfNotNull(filesDir, getExternalFilesDir(null)).mapNotNull { + runCatching { it.canonicalFile }.getOrNull() + } + return file.takeIf { candidate -> + allowedRoots.any { root -> + candidate == root || candidate.path.startsWith(root.path + File.separator) + } + } + } + + private fun canonicalExportSource(path: String): File? { + val original = File(path).absoluteFile + val canonical = canonicalAppScopedFile(path) ?: return null + return canonical.takeIf { + original.path == canonical.path && (canonical.isFile || canonical.isDirectory) + } + } + + private fun snapshotExportSource(source: File): ExportSource? { + val safeSource = canonicalExportSource(source.path) ?: return null + if (safeSource.isFile) return ExportSource(safeSource, null) + val sourceChildren = safeSource.listFiles() ?: return null + val children = ArrayList(sourceChildren.size) + for (child in sourceChildren) { + val snapshot = snapshotExportSource(child) ?: return null + children.add(snapshot) + } + return ExportSource(safeSource, children) + } + + private fun copyExportSourceToTree( + treeUri: Uri, + parentDocId: String, + source: ExportSource + ): Boolean { + val children = source.children + return if (children == null) { + copyFileToTree(treeUri, parentDocId, source.file) + } else { + copyDirToTree(treeUri, parentDocId, source) + } + } + + private fun treeDisplayName(treeUri: Uri): String? { + return try { + val rootDocId = DocumentsContract.getTreeDocumentId(treeUri) + val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, rootDocId) + contentResolver.query( + docUri, + arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), + null, + null, + null + )?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + } catch (e: Exception) { + Log.w(logTag, "Failed to read selected folder name", e) + null + } + } + + private fun copyDocumentTreeToFile(treeUri: Uri, destinationDir: File): Boolean { + val rootDocId = DocumentsContract.getTreeDocumentId(treeUri) + return copyChildrenToFile(treeUri, rootDocId, destinationDir) + } + + private fun copyChildrenToFile( + treeUri: Uri, + parentDocId: String, + destinationDir: File + ): Boolean { + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId) + var ok = true + val destinationNames = HashSet() + val cursor = contentResolver.query(childrenUri, childColumns, null, null, null) + ?: return false + cursor.use { + while (cursor.moveToNext()) { + val docId = cursor.getString(0) + val name = cursor.getString(1) + val mime = cursor.getString(2) + val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId) + if (name != null && !destinationNames.add(name)) { + ok = false + continue + } + val destination = safeDestinationChild(destinationDir, name) + if (destination == null || destination.exists()) { + ok = false + continue + } + if (mime == DocumentsContract.Document.MIME_TYPE_DIR) { + if (!destination.mkdirs() && !destination.isDirectory) { + ok = false + continue + } + if (!copyChildrenToFile(treeUri, docId, destination)) { + ok = false + } + } else if (!copyDocumentToFile(docUri, destination)) { + ok = false + } + } + } + return ok + } + + private fun safeDestinationChild(destinationDir: File, name: String?): File? { + if (name.isNullOrEmpty() || name == "." || name == ".." || + name.indexOf('\u0000') >= 0 || name.contains('/') || name.contains('\\')) { + return null + } + val parent = runCatching { destinationDir.canonicalFile }.getOrNull() ?: return null + val child = runCatching { File(parent, name).canonicalFile }.getOrNull() ?: return null + return child.takeIf { it.path.startsWith(parent.path + File.separator) } + } + + private fun copyDocumentToFile(uri: Uri, destination: File): Boolean { + return try { + destination.parentFile?.mkdirs() + if (destination.exists() && !destination.delete()) { + return false + } + contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(destination).use { output -> input.copyTo(output) } + } != null + } catch (e: Exception) { + Log.e(logTag, "Failed to copy document to $destination", e) + false + } + } + + private fun copyFileToTree(treeUri: Uri, parentDocId: String, source: File): Boolean { + val safeSource = canonicalExportSource(source.path)?.takeIf { it.isFile } ?: return false + return try { + val mime = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(safeSource.extension.lowercase()) + ?: "application/octet-stream" + val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId) + val docUri = DocumentsContract.createDocument( + contentResolver, + parentUri, + mime, + safeSource.name + ) ?: return false + contentResolver.openOutputStream(docUri, "wt")?.use { output -> + FileInputStream(safeSource).use { input -> input.copyTo(output) } + } ?: return false + true + } catch (e: Exception) { + Log.e(logTag, "Failed to export file $safeSource", e) + false + } + } + + private fun copyDirToTree( + treeUri: Uri, + parentDocId: String, + source: ExportSource + ): Boolean { + val children = source.children ?: return false + val safeSource = canonicalExportSource(source.file.path)?.takeIf { it.isDirectory } + ?: return false + val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId) + var dirDocId = findChildDocId(treeUri, parentDocId, safeSource.name) + if (dirDocId == null) { + dirDocId = try { + DocumentsContract.createDocument( + contentResolver, + parentUri, + DocumentsContract.Document.MIME_TYPE_DIR, + safeSource.name + )?.let { DocumentsContract.getDocumentId(it) } + } catch (e: Exception) { + Log.e(logTag, "Failed to create folder ${safeSource.name}", e) + null + } + } + if (dirDocId == null) return false + + var ok = true + children.forEach { child -> + val childOk = copyExportSourceToTree(treeUri, dirDocId, child) + if (!childOk) ok = false + } + return ok + } + + private fun findChildDocId(treeUri: Uri, parentDocId: String, name: String): String? { + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId) + val cursor = contentResolver.query(childrenUri, childColumns, null, null, null) + ?: throw IllegalStateException("Unable to query destination folder") + cursor.use { + while (cursor.moveToNext()) { + if (cursor.getString(1) == name && + cursor.getString(2) == DocumentsContract.Document.MIME_TYPE_DIR + ) { + return cursor.getString(0) + } + } + } + return null + } + + private val childColumns = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE + ) + + private fun displayName(uri: Uri): String? { + return try { + contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + } catch (e: Exception) { + Log.w(logTag, "Failed to read selected document name", e) + null + } + } + private fun setCodecInfo() { val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS) val codecs = codecList.codecInfos diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt index b03b63844..4648b9adc 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt @@ -214,6 +214,17 @@ class MainService : Service() { // video private var mediaProjection: MediaProjection? = null + private val mediaProjectionCallback = object : MediaProjection.Callback() { + override fun onStop() { + Log.d(logTag, "MediaProjection stopped") + stopCapture() + virtualDisplay?.release() + virtualDisplay = null + releaseMediaProjection() + _isReady = false + checkMediaPermission() + } + } private var surface: Surface? = null private val sendVP9Thread = Executors.newSingleThreadExecutor() private var videoEncoder: MediaCodec? = null @@ -243,7 +254,9 @@ class MainService : Service() { // keep the config dir same with flutter val prefs = applicationContext.getSharedPreferences(KEY_SHARED_PREFERENCES, FlutterActivity.MODE_PRIVATE) val configPath = prefs.getString(KEY_APP_DIR_CONFIG_PATH, "") ?: "" - FFI.startServer(configPath, "") + val homePath = applicationContext.getExternalFilesDir(null)?.absolutePath + ?: applicationContext.filesDir.absolutePath + FFI.startServer(configPath, homePath, "") createForegroundNotification() } @@ -347,10 +360,13 @@ class MainService : Service() { getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager intent.getParcelableExtra(EXT_MEDIA_PROJECTION_RES_INTENT)?.let { - mediaProjection = + releaseMediaProjection() + val projection = mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, it) - checkMediaPermission() + projection.registerCallback(mediaProjectionCallback, Handler(Looper.getMainLooper())) + mediaProjection = projection _isReady = true + checkMediaPermission() } ?: let { Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection") requestMediaProjection() @@ -372,6 +388,12 @@ class MainService : Service() { startActivity(intent) } + private fun releaseMediaProjection() { + mediaProjection?.unregisterCallback(mediaProjectionCallback) + mediaProjection?.stop() + mediaProjection = null + } + @SuppressLint("WrongConstant") private fun createSurface(): Surface? { return if (useVP9) { @@ -496,7 +518,7 @@ class MainService : Service() { virtualDisplay = null } - mediaProjection = null + releaseMediaProjection() checkMediaPermission() stopForeground(true) stopService(Intent(this, FloatingWindowService::class.java)) diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt index 514d493b9..2923cad9f 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt @@ -38,6 +38,10 @@ const val EXT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY" // Activity requestCode const val REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION = 101 const val REQ_REQUEST_MEDIA_PROJECTION = 201 +const val REQ_EXPORT_FILE = 301 +const val REQ_IMPORT_FILES = 302 +const val REQ_IMPORT_DIRECTORY = 303 +const val REQ_EXPORT_FILES = 304 // Activity responseCode const val RES_FAILED = -100 @@ -47,6 +51,12 @@ const val START_ACTION = "start_action" const val GET_START_ON_BOOT_OPT = "get_start_on_boot_opt" const val SET_START_ON_BOOT_OPT = "set_start_on_boot_opt" const val SYNC_APP_DIR_CONFIG_PATH = "sync_app_dir" +const val PICK_IMPORT_FILES = "pick_import_files" +const val IMPORT_FILE = "import_file" +const val EXPORT_FILE = "export_file" +const val PICK_IMPORT_DIRECTORY = "pick_import_directory" +const val IMPORT_DIRECTORY = "import_directory" +const val EXPORT_FILES = "export_files" const val GET_VALUE = "get_value" const val KEY_IS_SUPPORT_VOICE_CALL = "KEY_IS_SUPPORT_VOICE_CALL" diff --git a/flutter/android/app/src/main/kotlin/ffi.kt b/flutter/android/app/src/main/kotlin/ffi.kt index 89e3dc046..02e6606ae 100644 --- a/flutter/android/app/src/main/kotlin/ffi.kt +++ b/flutter/android/app/src/main/kotlin/ffi.kt @@ -15,7 +15,7 @@ object FFI { external fun init(ctx: Context) external fun onAppStart(ctx: Context) external fun setClipboardManager(clipboardManager: RdClipboardManager) - external fun startServer(app_dir: String, custom_client_config: String) + external fun startServer(app_dir: String, home_dir: String, custom_client_config: String) external fun startService() external fun onVideoFrameUpdate(buf: ByteBuffer) external fun onAudioFrameUpdate(buf: ByteBuffer) diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index 93c7a4d4b..25eed4259 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -1519,13 +1519,6 @@ class AndroidPermissionManager { static Timer? _timer; static var _current = ""; - static bool isWaitingFile() { - if (_completer != null) { - return !_completer!.isCompleted && _current == kManageExternalStorage; - } - return false; - } - static Future check(String type) { if (isDesktop || isWeb) { return Future.value(true); @@ -2634,13 +2627,6 @@ connect(BuildContext context, String id, } } else { if (isFileTransfer) { - if (isAndroid) { - if (!await AndroidPermissionManager.check(kManageExternalStorage)) { - if (!await AndroidPermissionManager.request(kManageExternalStorage)) { - return; - } - } - } if (isWeb) { Navigator.push( context, diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 10459e782..ca0bd523f 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -439,7 +439,6 @@ const kActionApplicationDetailsSettings = const kActionAccessibilitySettings = "android.settings.ACCESSIBILITY_SETTINGS"; const kRecordAudio = "android.permission.RECORD_AUDIO"; -const kManageExternalStorage = "android.permission.MANAGE_EXTERNAL_STORAGE"; const kRequestIgnoreBatteryOptimizations = "android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"; const kSystemAlertWindow = "android.permission.SYSTEM_ALERT_WINDOW"; @@ -451,6 +450,12 @@ class AndroidChannel { static final kGetStartOnBootOpt = "get_start_on_boot_opt"; static final kSetStartOnBootOpt = "set_start_on_boot_opt"; static final kSyncAppDirConfigPath = "sync_app_dir"; + static final kPickImportFiles = "pick_import_files"; + static final kImportFile = "import_file"; + static final kExportFile = "export_file"; + static final kPickImportDirectory = "pick_import_directory"; + static final kImportDirectory = "import_directory"; + static final kExportFiles = "export_files"; } /// flutter/packages/flutter/lib/src/services/keyboard_key.dart -> _keyLabels diff --git a/flutter/lib/mobile/pages/file_manager_page.dart b/flutter/lib/mobile/pages/file_manager_page.dart index 982a4c805..e389bdf6c 100644 --- a/flutter/lib/mobile/pages/file_manager_page.dart +++ b/flutter/lib/mobile/pages/file_manager_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_breadcrumb/flutter_breadcrumb.dart'; @@ -8,6 +9,7 @@ import 'package:toggle_switch/toggle_switch.dart'; import '../../common.dart'; import '../../common/widgets/dialog.dart'; +import '../../consts.dart'; class FileManagerPage extends StatefulWidget { FileManagerPage( @@ -73,6 +75,173 @@ class _FileManagerPageState extends State { DirectoryOptions get currentOptions => currentFileController.options.value; final _uniqueKey = UniqueKey(); + Future _runAndroidDocumentPicker(Future Function() action) async { + gFFI.ffiModel.beginAndroidDocumentPicker(); + try { + return await action(); + } finally { + gFFI.ffiModel.endAndroidDocumentPicker(); + } + } + + Future _importFiles() async { + var imported = 0; + var failed = false; + final importController = currentFileController; + final importDirectory = currentDir.path; + final importIsWindows = currentOptions.isWindows; + try { + final selectedFiles = await _runAndroidDocumentPicker(() => + gFFI.invokeMethodWithResult>( + AndroidChannel.kPickImportFiles)); + if (selectedFiles == null || selectedFiles.isEmpty) return; + + for (final selected in selectedFiles) { + final uri = (selected as Map)['uri'] as String?; + final selectedName = selected['name'] as String?; + final name = selectedName?.replaceAll('\\', '/').split('/').last; + if (uri == null || + name == null || + !PathUtil.validName(name, importIsWindows)) { + failed = true; + continue; + } + final destination = + PathUtil.join(importDirectory, name, importIsWindows); + var overwrite = false; + if (await File(destination).exists()) { + final overwriteResult = await model.showFileConfirmDialog( + translate('Overwrite'), destination, false, false); + if (overwriteResult == false) break; + if (overwriteResult != true) continue; + overwrite = true; + } + try { + final success = await gFFI.invokeMethod( + AndroidChannel.kImportFile, + {'uri': uri, 'path': destination, 'overwrite': overwrite}); + if (success == true) { + imported++; + } else { + failed = true; + } + } catch (e) { + failed = true; + debugPrint('Failed to import $name: $e'); + } + } + } catch (e) { + failed = true; + debugPrint('Failed to select files for import: $e'); + } + await importController.refresh(); + if (failed) { + showToast(translate('Failed')); + } else if (imported > 0) { + showToast(translate('Successful')); + } + } + + Future _exportFile(Entry entry) async { + try { + final exported = await _runAndroidDocumentPicker(() => gFFI + .invokeMethod(AndroidChannel.kExportFile, {'path': entry.path})); + if (exported == true) { + showToast(translate('Successful')); + } + } catch (e) { + debugPrint('Failed to export ${entry.name}: $e'); + showToast(translate('Failed')); + } + } + + Future _importFolder() async { + final importController = currentFileController; + final importDirectory = currentDir.path; + final importIsWindows = currentOptions.isWindows; + try { + final picked = await _runAndroidDocumentPicker(() => + gFFI.invokeMethodWithResult>( + AndroidChannel.kPickImportDirectory)); + if (picked == null || picked.isEmpty) return; + final uri = picked['uri'] as String?; + final name = + (picked['name'] as String?)?.replaceAll('\\', '/').split('/').last; + if (uri == null || + name == null || + name == '.' || + name == '..' || + !PathUtil.validName(name, importIsWindows)) { + showToast(translate('Failed')); + return; + } + final destination = PathUtil.join(importDirectory, name, importIsWindows); + final destinationType = await FileSystemEntity.type(destination); + var overwrite = false; + if (destinationType == FileSystemEntityType.directory) { + final overwriteResult = await model.showFileConfirmDialog( + translate('Overwrite'), destination, false, false); + if (overwriteResult != true) return; + overwrite = true; + } else if (destinationType != FileSystemEntityType.notFound) { + showToast(translate('Failed')); + return; + } + final success = await gFFI.invokeMethod(AndroidChannel.kImportDirectory, + {'uri': uri, 'path': destination, 'overwrite': overwrite}); + if (success == true) { + showToast(translate('Successful')); + } else { + showToast(translate('Failed')); + } + } catch (e) { + debugPrint('Failed to import folder: $e'); + showToast(translate('Failed')); + } + await importController.refresh(); + } + + Future _exportItems(SelectedItems items) async { + await _exportPaths(items.items.map((e) => e.path)); + } + + Future _exportLogs() async { + final home = currentFileController.homePath; + if (home.isEmpty) { + showToast(translate('Failed')); + return; + } + final appDir = PathUtil.join(home, appName, false); + final paths = [ + PathUtil.join(appDir, 'Logs', false), + PathUtil.join(appDir, 'ScreenRecord', false), + ].where((p) => File(p).existsSync() || Directory(p).existsSync()).toList(); + if (paths.isEmpty) { + showToast(translate('Failed')); + return; + } + await _exportPaths(paths); + } + + Future _exportPaths(Iterable paths) async { + try { + final result = await _runAndroidDocumentPicker(() => + gFFI.invokeMethodWithResult>( + AndroidChannel.kExportFiles, {'paths': paths.toList()})); + if (result == null) return; + final exported = result['exported'] as int? ?? 0; + final failed = result['failed'] as int? ?? 0; + if (failed > 0) { + showToast(translate('Failed')); + } else if (exported > 0) { + showToast(translate('Successful')); + } + } catch (e) { + debugPrint('Failed to export paths: $e'); + showToast(translate('Failed')); + } + } + @override void initState() { super.initState(); @@ -159,6 +328,45 @@ class _FileManagerPageState extends State { ), value: "refresh", ), + if (isAndroid) + PopupMenuItem( + enabled: showLocal && currentDir.path.isNotEmpty, + value: "import", + child: Row( + children: [ + Icon(Icons.add_to_drive, + color: Theme.of(context).iconTheme.color), + SizedBox(width: 5), + Text(translate("Add")) + ], + ), + ), + if (isAndroid) + PopupMenuItem( + enabled: showLocal && currentDir.path.isNotEmpty, + value: "import_folder", + child: Row( + children: [ + Icon(Icons.create_new_folder_outlined, + color: Theme.of(context).iconTheme.color), + SizedBox(width: 5), + Text(translate("Import Folder")) + ], + ), + ), + if (isAndroid) + PopupMenuItem( + enabled: showLocal && currentDir.path.isNotEmpty, + value: "export_logs", + child: Row( + children: [ + Icon(Icons.article_outlined, + color: Theme.of(context).iconTheme.color), + SizedBox(width: 5), + Text(translate("Export Logs")) + ], + ), + ), PopupMenuItem( enabled: currentDir.path != "/", child: Row( @@ -203,6 +411,12 @@ class _FileManagerPageState extends State { onSelected: (v) { if (v == "refresh") { currentFileController.refresh(); + } else if (v == "import") { + _importFiles(); + } else if (v == "import_folder") { + _importFolder(); + } else if (v == "export_logs") { + _exportLogs(); } else if (v == "select") { model.localController.selectedItems.clear(); model.remoteController.selectedItems.clear(); @@ -300,6 +514,24 @@ class _FileManagerPageState extends State { setState(() {}); }, actions: [ + if (isAndroid && + selectedItems?.isLocal == true && + selectedItems?.items.isNotEmpty == true) ...[ + if (selectedItems!.items.length == 1 && + selectedItems!.items.single.isFile) + IconButton( + tooltip: translate("Save as"), + icon: Icon(Icons.save_alt), + onPressed: () => + _exportFile(selectedItems!.items.single), + ) + else + IconButton( + tooltip: translate("Export"), + icon: Icon(Icons.drive_folder_upload), + onPressed: () => _exportItems(selectedItems!), + ), + ], IconButton( icon: Icon(Icons.compare_arrows), onPressed: () => setState(() => showLocal = !showLocal), diff --git a/flutter/lib/mobile/pages/server_page.dart b/flutter/lib/mobile/pages/server_page.dart index cd3f97a53..d61cf70b8 100644 --- a/flutter/lib/mobile/pages/server_page.dart +++ b/flutter/lib/mobile/pages/server_page.dart @@ -225,12 +225,6 @@ class _ServerPageState extends State { void checkService() async { gFFI.invokeMethod("check_service"); - // for Android 10/11, request MANAGE_EXTERNAL_STORAGE permission from system setting page - if (AndroidPermissionManager.isWaitingFile() && !gFFI.serverModel.fileOk) { - AndroidPermissionManager.complete(kManageExternalStorage, - await AndroidPermissionManager.check(kManageExternalStorage)); - debugPrint("file permission finished"); - } } class ServiceNotRunningNotification extends StatelessWidget { diff --git a/flutter/lib/models/file_model.dart b/flutter/lib/models/file_model.dart index 22bf1eab6..26396bce5 100644 --- a/flutter/lib/models/file_model.dart +++ b/flutter/lib/models/file_model.dart @@ -381,6 +381,14 @@ class FileController { void set homePath(String path) => options.value.home = path; OverlayDialogManager? get dialogManager => rootState.target?.dialogManager; + bool _isPathAllowed(String candidate) { + if (!isAndroid || !isLocal) return true; + if (homePath.isEmpty || candidate.isEmpty) return false; + final home = PathUtil.posixContext.normalize(homePath); + final target = PathUtil.posixContext.normalize(candidate); + return target == home || PathUtil.posixContext.isWithin(home, target); + } + String get shortPath { final dirPath = directory.value.path; if (dirPath.startsWith(homePath)) { @@ -414,8 +422,13 @@ class FileController { await Future.delayed(Duration(milliseconds: 100)); - final savedDir = (await bind.sessionGetPeerOption( + var savedDir = (await bind.sessionGetPeerOption( sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir")); + if (savedDir.isNotEmpty && !_isPathAllowed(savedDir)) { + savedDir = options.value.home; + await bind.sessionPeerOption( + sessionId: sessionId, name: "local_dir", value: savedDir); + } Future tryOpenReadyDirs() async { final dirs = { if (directory.value.path.isNotEmpty) directory.value.path, @@ -485,6 +498,9 @@ class FileController { } Future _openDirectoryPath(String path, {bool isBack = false}) async { + if (!_isPathAllowed(path)) { + return false; + } if (!isBack) { pushHistory(); } @@ -504,6 +520,7 @@ class FileController { return true; } fd.format(isWindows, sort: sortBy.value); + selectedItems.reconcile(fd.entries); directory.value = fd; return true; } catch (e) { @@ -550,6 +567,9 @@ class FileController { final isWindows = options.value.isWindows; final dirPath = directory.value.path; var parent = PathUtil.dirname(dirPath, isWindows); + if (!_isPathAllowed(parent)) { + return true; + } // specially for C:\, D:\, goto '/' if (parent == dirPath && isWindows) { return await _openDirectoryPath('/', isBack: isBack); @@ -1885,7 +1905,7 @@ class PathUtil { } static bool validName(String name, bool isWindows) { - final unixFileNamePattern = RegExp(r'^[^/\0]+$'); + final unixFileNamePattern = RegExp(r'^[^/\x00]+$'); final windowsFileNamePattern = RegExp(r'^[^<>:"/\\|?*]+$'); final reg = isWindows ? windowsFileNamePattern : unixFileNamePattern; return reg.hasMatch(name); @@ -1928,6 +1948,21 @@ class SelectedItems { items.clear(); } + void reconcile(List entries) { + if (items.isEmpty) return; + final currentByPath = {for (final entry in entries) entry.path: entry}; + final reconciled = []; + for (final item in items) { + final current = currentByPath[item.path]; + if (current != null && current.entryType == item.entryType) { + reconciled.add(current); + } + } + items + ..clear() + ..addAll(reconciled); + } + void selectAll(List entries) { items.clear(); items.addAll(entries); diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index bd564ba3b..e22782034 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -124,6 +124,8 @@ class FfiModel with ChangeNotifier { Timer? _restartReconnectDelayTimer; var _reconnects = 1; DateTime? _offlineReconnectStartTime; + bool _androidDocumentPickerActive = false; + bool _androidDocumentPickerInterruptedConnection = false; bool _viewOnly = false; bool _showMyCursor = false; WeakReference parent; @@ -255,6 +257,8 @@ class FfiModel with ChangeNotifier { _inputBlocked = false; _timer?.cancel(); _timer = null; + _androidDocumentPickerActive = false; + _androidDocumentPickerInterruptedConnection = false; resetRestartReconnectState(); clearPermissions(); waitForImageTimer?.cancel(); @@ -892,6 +896,13 @@ class FfiModel with ChangeNotifier { final text = evt['text']; final link = evt['link']; + if (isAndroid && + _androidDocumentPickerActive && + title == 'Connection Error') { + _androidDocumentPickerInterruptedConnection = true; + return; + } + // Disable relative mouse mode on any error-type message to ensure cursor is released. // This includes connection errors, session-ending messages, elevation errors, etc. // Safety: releasing pointer lock on errors prevents the user from being stuck. @@ -968,6 +979,23 @@ class FfiModel with ChangeNotifier { _restartReconnectDelayTimer = null; } + void beginAndroidDocumentPicker() { + if (!isAndroid) return; + _androidDocumentPickerActive = true; + _androidDocumentPickerInterruptedConnection = false; + } + + void endAndroidDocumentPicker() { + if (!isAndroid) return; + _androidDocumentPickerActive = false; + if (!_androidDocumentPickerInterruptedConnection || + parent.target?.closed == true) { + return; + } + _androidDocumentPickerInterruptedConnection = false; + reconnect(parent.target!.dialogManager, sessionId, false); + } + /// Auto-retry check for "Remote desktop is offline" error. /// returns true to auto-retry, false otherwise. bool shouldAutoRetryOnOffline( @@ -4060,6 +4088,11 @@ class FFI { return await platformFFI.invokeMethod(method, arguments); } + Future invokeMethodWithResult(String method, + [dynamic arguments]) async { + return await platformFFI.invokeMethodWithResult(method, arguments); + } + // Terminal model management void registerTerminalModel(int terminalId, TerminalModel model) { debugPrint('[FFI] Registering terminal model for terminal $terminalId'); diff --git a/flutter/lib/models/native_model.dart b/flutter/lib/models/native_model.dart index 8c3c5cf71..93f06d55d 100644 --- a/flutter/lib/models/native_model.dart +++ b/flutter/lib/models/native_model.dart @@ -4,7 +4,6 @@ import 'dart:io'; import 'dart:ui' as ui; import 'package:device_info_plus/device_info_plus.dart'; -import 'package:external_path/external_path.dart'; import 'package:ffi/ffi.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -171,8 +170,10 @@ class PlatformFFI { _startListenEvent(_ffiBind); // global event try { if (isAndroid) { - // only support for android - _homeDir = (await ExternalPath.getExternalStorageDirectories())[0]; + // Android file transfer uses app-specific storage. User-selected + // files enter and leave this workspace through the system picker. + _homeDir = (await getExternalStorageDirectory())?.path ?? + (await getApplicationSupportDirectory()).path; } else if (isIOS) { // The previous code was `_homeDir = (await getDownloadsDirectory())?.path ?? '';`, // which provided the `downloads` path in the sandbox. @@ -306,6 +307,12 @@ class PlatformFFI { return await _toAndroidChannel.invokeMethod(method, arguments); } + Future invokeMethodWithResult(String method, + [dynamic arguments]) async { + if (!isAndroid) return null; + return await _toAndroidChannel.invokeMethod(method, arguments); + } + void syncAndroidServiceAppDirConfigPath() { invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir); } diff --git a/flutter/lib/models/server_model.dart b/flutter/lib/models/server_model.dart index 6e78ad17f..031a62509 100644 --- a/flutter/lib/models/server_model.dart +++ b/flutter/lib/models/server_model.dart @@ -210,15 +210,10 @@ class ServerModel with ChangeNotifier { _audioOk = audioOption != 'N'; } - // file - if (!await AndroidPermissionManager.check(kManageExternalStorage)) { - _fileOk = false; - bind.mainSetOption(key: kOptionEnableFileTransfer, value: "N"); - } else { - final fileOption = - await bind.mainGetOption(key: kOptionEnableFileTransfer); - _fileOk = fileOption != 'N'; - } + // Android file transfer is confined to app-specific storage. Files enter + // and leave the workspace through Android's system document picker. + final fileOption = await bind.mainGetOption(key: kOptionEnableFileTransfer); + _fileOk = fileOption != 'N'; // clipboard final clipOption = await bind.mainGetOption(key: kOptionEnableClipboard); @@ -319,16 +314,6 @@ class ServerModel with ChangeNotifier { if (clients.any((c) => !c.disconnected)) { await showClientsMayNotBeChangedAlert(parent.target); } - if (!_fileOk && - !await AndroidPermissionManager.check(kManageExternalStorage)) { - final res = - await AndroidPermissionManager.request(kManageExternalStorage); - if (!res) { - showToast(translate('Failed')); - return; - } - } - _fileOk = !_fileOk; bind.mainSetOption( key: kOptionEnableFileTransfer, @@ -418,9 +403,6 @@ class ServerModel with ChangeNotifier { if (bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) != 'Y') { await checkFloatingWindowPermission(); } - if (!await AndroidPermissionManager.check(kManageExternalStorage)) { - await AndroidPermissionManager.request(kManageExternalStorage); - } final res = await parent.target?.dialogManager .show((setState, close, context) { submit() => close(true); diff --git a/flutter/lib/models/web_model.dart b/flutter/lib/models/web_model.dart index b65825e51..be8d83500 100644 --- a/flutter/lib/models/web_model.dart +++ b/flutter/lib/models/web_model.dart @@ -251,6 +251,11 @@ class PlatformFFI { return true; } + Future invokeMethodWithResult(String method, + [dynamic arguments]) async { + return null; + } + // just for compilation void syncAndroidServiceAppDirConfigPath() {} diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 1a90336ba..84dd9cb0e 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -409,14 +409,6 @@ packages: url: "https://pub.dev" source: hosted version: "12.0.1" - external_path: - dependency: "direct main" - description: - name: external_path - sha256: "2095c626fbbefe70d5a4afc9b1137172a68ee2c276e51c3c1283394485bea8f4" - url: "https://pub.dev" - source: hosted - version: "1.0.3" ffi: dependency: "direct main" description: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 198036834..d67ce0003 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -29,7 +29,6 @@ dependencies: ffi: ^2.1.0 path_provider: ^2.1.1 - external_path: ^1.0.3 provider: ^6.0.5 tuple: ^2.0.0 wakelock_plus: ^1.1.3 diff --git a/src/common.rs b/src/common.rs index e6993d074..648bc6b5c 100644 --- a/src/common.rs +++ b/src/common.rs @@ -222,6 +222,61 @@ pub fn need_fs_cm_send_files() -> bool { } } +/// Android is scoped-storage only: the peer may never touch anything outside the app +/// workspace (`Config::get_home()`, i.e. the app-specific external files directory). +/// +/// Every peer supplied path must be validated with this before it reaches the +/// filesystem, for reads, writes, renames, creations and deletions alike. The path is +/// resolved to its canonical form (of the deepest existing ancestor, so paths that are +/// about to be created are handled too) so symlinks cannot escape the workspace. +/// +/// Only the `ReadDir` protocol action treats an empty path as the home directory. +/// Callers must opt in to that protocol-specific behavior with `allow_empty`. +#[cfg(target_os = "android")] +pub fn is_peer_path_allowed(path: &str, allow_empty: bool) -> bool { + use std::path::{Component, Path, PathBuf}; + + // Canonicalize the deepest existing ancestor and re-append the missing tail. + fn resolve(path: &Path) -> Option { + let mut tail: Vec = Vec::new(); + let mut base = path.to_path_buf(); + loop { + if let Ok(mut resolved) = base.canonicalize() { + while let Some(component) = tail.pop() { + resolved.push(component); + } + return Some(resolved); + } + tail.push(base.file_name()?.to_os_string()); + if !base.pop() { + return None; + } + } + } + + if path.is_empty() { + return allow_empty; + } + let path = Path::new(path); + // `..` is never needed by the protocol and would defeat the prefix check below. + if !path.is_absolute() || path.components().any(|c| c == Component::ParentDir) { + return false; + } + let home = Config::get_home(); + let home = home.canonicalize().unwrap_or(home); + if home.as_os_str().is_empty() { + return false; + } + // `Path::starts_with` compares whole components, and is true for equal paths. + resolve(path).map_or(false, |target| target.starts_with(&home)) +} + +#[inline] +#[cfg(not(target_os = "android"))] +pub fn is_peer_path_allowed(_path: &str, _allow_empty: bool) -> bool { + true +} + #[inline] pub fn is_main() -> bool { *IS_MAIN diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 6d093cfab..1528376ab 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -2912,6 +2912,7 @@ pub mod server_side { env: JNIEnv, _class: JClass, app_dir: JString, + home_dir: JString, custom_client_config: JString, ) { log::debug!("startServer from jvm"); @@ -2919,6 +2920,9 @@ pub mod server_side { if let Ok(app_dir) = env.get_string(&app_dir) { *config::APP_DIR.write().unwrap() = app_dir.into(); } + if let Ok(home_dir) = env.get_string(&home_dir) { + *config::APP_HOME_DIR.write().unwrap() = home_dir.into(); + } if let Ok(custom_client_config) = env.get_string(&custom_client_config) { if !custom_client_config.is_empty() { let custom_client_config: String = custom_client_config.into(); diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 04d982ba9..33c80e6eb 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "لقطة الشاشة للشاشات المدمجة غير مدعومة"), ("screenshot-action-tip", "إجراء لقطة الشاشة"), ("Save as", "حفظ باسم"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "نسخ إلى الحافظة"), ("Enable remote printer", "تمكين الطابعة عن بُعد"), ("Downloading {}", "جارٍ تنزيل {}"), diff --git a/src/lang/be.rs b/src/lang/be.rs index 6d2c93882..7a1635c27 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Аб’яднанне здымкаў экранаў з некалькіх дысплэяў у дадзены момант не падтрымліваецца. Пераключыцеся на адзін з дысплэяў і паўтарыце дзеянне."), ("screenshot-action-tip", "Выберыце, што рабіць з атрыманым здымкам экрана."), ("Save as", "Захаваць у файл"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Скапіяваць у буфер абмену"), ("Enable remote printer", "Выкарыстоўваць аддалены прынтар"), ("Downloading {}", "Ідзе спампоўванне {}"), diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 83c98545e..d9f7cf842 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Обединяването на снимки от няколко екрана в момента не се поддържа. Моля, превключете към един екран и опитайте отново."), ("screenshot-action-tip", "Моля, изберете как да продължите със снимката на екрана."), ("Save as", "Запазване като"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Копиране в клипборда"), ("Enable remote printer", "Позволяване на отдалечен принтер"), ("Downloading {}", "Изтегляне на {}"), diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 9b0ebb085..196574688 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Actualment no és possible combinar captures de pantalla de diverses pantalles. Canvieu a una sola pantalla i torneu a provar."), ("screenshot-action-tip", "Seleccioneu com voleu continuar amb la captura de pantalla."), ("Save as", "Anomena i desa"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copia al porta-retalls"), ("Enable remote printer", "Habilita l'impressora remota"), ("Downloading {}", "Descarregant {}"), diff --git a/src/lang/cn.rs b/src/lang/cn.rs index 191c25908..be998606b 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "当前不支持多个屏幕的合并截屏,请切换到单个屏幕重试。"), ("screenshot-action-tip", "请选择如何继续截屏。"), ("Save as", "另存为"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "复制到剪贴板"), ("Enable remote printer", "启用远程打印机"), ("Downloading {}", "正在下载 {}"), diff --git a/src/lang/cs.rs b/src/lang/cs.rs index 1d214e024..21daea69c 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Sloučení snímků obrazovky z více displejů aktuálně není podporováno. Přepněte na jeden displej a zkuste to znovu."), ("screenshot-action-tip", "Vyberte, jak pokračovat se snímkem obrazovky."), ("Save as", "Uložit jako"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopírovat do schránky"), ("Enable remote printer", "Povolit vzdálenou tiskárnu"), ("Downloading {}", "Stahuje se {}"), diff --git a/src/lang/da.rs b/src/lang/da.rs index 38447d724..b29e3eddf 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Sammenfletning af skærmbilleder fra flere skærme understøttes ikke i øjeblikket. Skift venligst til en enkelt skærm og prøv igen."), ("screenshot-action-tip", "Vælg venligst, hvordan du vil fortsætte med skærmbilledet."), ("Save as", "Gem som"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiér til udklipsholder"), ("Enable remote printer", "Aktivér fjernprinter"), ("Downloading {}", "Downloader {}"), diff --git a/src/lang/de.rs b/src/lang/de.rs index 833be3fca..9c6e75bc6 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Das Zusammenführen von Screenshots von mehreren Bildschirmen wird derzeit nicht unterstützt. Bitte wechseln Sie zu einem einzelnen Bildschirm und versuchen Sie es erneut."), ("screenshot-action-tip", "Bitte wählen Sie aus, wie Sie mit dem Screenshot fortfahren möchten."), ("Save as", "Speichern unter"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "In Zwischenablage kopieren"), ("Enable remote printer", "Entfernten Drucker aktivieren"), ("Downloading {}", "{} herunterladen"), diff --git a/src/lang/el.rs b/src/lang/el.rs index cc7591ea3..d3d1e378f 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Η συγχώνευση στιγμιότυπων οθόνης από πολλές οθόνες δεν υποστηρίζεται προς το παρόν. Αλλάξτε σε μία μόνο οθόνη και δοκιμάστε ξανά."), ("screenshot-action-tip", "Επιλέξτε πώς θα συνεχίσετε με το στιγμιότυπο οθόνης."), ("Save as", "Αποθήκευση ως"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Αντιγραφή στο πρόχειρο"), ("Enable remote printer", "Ενεργοποίηση απομακρυσμένου εκτυπωτή"), ("Downloading {}", "Γίνεται Λήψη {}"), diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 48a49f96d..4f9b0ccd7 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Kunfandi ekrankopiojn de pluraj ekranoj aktuale ne estas subtenata. Bonvolu ŝanĝi al unu ekrano kaj reprovi."), ("screenshot-action-tip", "Bonvolu elekti kiel daŭrigi kun la ekrankopio."), ("Save as", "Konservi kiel"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopii al la poŝo"), ("Enable remote printer", "Ebligi foran presilon"), ("Downloading {}", "Elŝutas {}"), diff --git a/src/lang/es.rs b/src/lang/es.rs index b481fce7f..89926b43a 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "La fusión de capturas de pantalla de múltiples monitores no está soportada. Por favor, cambie a un monitor e inténtelo de nuevo."), ("screenshot-action-tip", "Por favor, seleccione cómo continuar con la captura de pantalla."), ("Save as", "Guardar como"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copiar al portapapeles"), ("Enable remote printer", "Habilitar impresora remota"), ("Downloading {}", "Descargando {}"), diff --git a/src/lang/et.rs b/src/lang/et.rs index d916df419..9bbb7b07d 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Mitme kuva kuvatõmmiste ühendamine pole praegu toetatud. Palun lülitu ühele kuvale ja proovi uuesti."), ("screenshot-action-tip", "Palun vali, kuidas kuvatõmmisega jätkata."), ("Save as", "Salvesta kui"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopeeri lõikelauale"), ("Enable remote printer", "Luba kaugprinter"), ("Downloading {}", "Allalaadimine: {}"), diff --git a/src/lang/eu.rs b/src/lang/eu.rs index e74f3a285..8515e6f53 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Pantaila anitzen pantaila-argazkiak bateratzea ez da onartzen une honetan. Aldatu pantaila bakarrera eta saiatu berriro."), ("screenshot-action-tip", "Hautatu pantaila-argazkiarekin nola jarraitu."), ("Save as", "Gorde honela"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiatu arbelera"), ("Enable remote printer", "Gaitu urruneko inprimagailua"), ("Downloading {}", "{} deskargatzen"), diff --git a/src/lang/fa.rs b/src/lang/fa.rs index c9fd7b45e..a96fe7160 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "ادغام تصاویر از نمایشگرهای متعدد در حال حاضر پشتیبانی نمی شود. لطفاً به یک صفحه نمایش واحد تغییر دهید و دوباره امتحان کنید."), ("screenshot-action-tip", "لطفاً نحوه ادامه با تصویر را انتخاب کنید."), ("Save as", "ذخیره به عنوان"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "در کلیپ بورد کپی کنید"), ("Enable remote printer", "چاپگر از راه دور را فعال کنید"), ("Downloading {}", "بارگیری {}"), diff --git a/src/lang/fi.rs b/src/lang/fi.rs index 9cb8e8de1..d5695d18d 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Yhdistetyn näytön kuvakaappaus ei ole tuettu"), ("screenshot-action-tip", "Valitse, mitä haluat tehdä kuvakaappaukselle"), ("Save as", "Tallenna nimellä"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopioi leikepöydälle"), ("Enable remote printer", "Ota etätulostin käyttöön"), ("Downloading {}", "Ladataan {}"), diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 5b3204053..4dece7adc 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Actuellement, la prise de capture d’écran ne prend pas en charge les affichages multiples. Veuillez réessayer après avoir sélectionné un seul affichage."), ("screenshot-action-tip", "Veuillez choisir l’action à effectuer avec la capture d’écran."), ("Save as", "Enregistrer sous"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copier dans le presse-papier"), ("Enable remote printer", "Activer l’impression à distance"), ("Downloading {}", "Téléchargement de {}"), diff --git a/src/lang/ge.rs b/src/lang/ge.rs index 988570095..a422c4853 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "რამდენიმე ეკრანის სურათის გაერთიანება ამჟამად მხარდაჭერილი არ არის. გადართეთ ერთ ეკრანზე და სცადეთ ხელახლა."), ("screenshot-action-tip", "აირჩიეთ, როგორ გავაგრძელოთ ეკრანის სურათთან მუშაობა."), ("Save as", "შენახვა როგორც"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "ბუფერში კოპირება"), ("Enable remote printer", "დისტანციური პრინტერის ჩართვა"), ("Downloading {}", "მიმდინარეობს {}-ის ჩამოტვირთვა"), diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 7825c5204..3a1c14139 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "મર્જ કરેલ સ્ક્રીનશોટ સપોર્ટેડ નથી."), ("screenshot-action-tip", "સ્ક્રીનશોટ પછીની ક્રિયા"), ("Save as", "તરીકે સાચવો"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "ક્લિપબોર્ડમાં કોપી કરો"), ("Enable remote printer", "રિમોટ પ્રિન્ટર સક્ષમ કરો"), ("Downloading {}", "{} ડાઉનલોડ થઈ રહ્યું છે"), diff --git a/src/lang/he.rs b/src/lang/he.rs index 1183a3cbb..2ff95b48f 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "צילום מסך משולב מכל המסכים אינו נתמך"), ("screenshot-action-tip", "בחר פעולה לאחר צילום המסך"), ("Save as", "שמור בשם"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "העתק ללוח"), ("Enable remote printer", "אפשר מדפסת מרוחקת"), ("Downloading {}", "מוריד את {}"), diff --git a/src/lang/hi.rs b/src/lang/hi.rs index d73b381c0..da7fc6a40 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "मर्ज की गई स्क्रीन के स्क्रीनशॉट समर्थित नहीं हैं।"), ("screenshot-action-tip", "स्क्रीनशॉट लेने के बाद की कार्रवाई"), ("Save as", "इस रूप में सहेजें"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "क्लिपबोर्ड पर कॉपी करें"), ("Enable remote printer", "रिमोट प्रिंटर सक्षम करें"), ("Downloading {}", "{} डाउनलोड हो रहा है"), diff --git a/src/lang/hr.rs b/src/lang/hr.rs index 7a0f9d3cf..20f5b0da1 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Spajanje snimaka zaslona s više zaslona trenutačno nije podržano. Prebacite se na jedan zaslon i pokušajte ponovno."), ("screenshot-action-tip", "Odaberite kako nastaviti sa snimkom zaslona."), ("Save as", "Spremi kao"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiraj u međuspremnik"), ("Enable remote printer", "Omogući udaljeni pisač"), ("Downloading {}", "Preuzimanje {}"), diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 705dc867c..28f3d0482 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Egyesített képernyőről nem támogatott a képernyőkép készítése"), ("screenshot-action-tip", "Képernyőkép-művelet"), ("Save as", "Mentés másként"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Másolás a vágólapra"), ("Enable remote printer", "Távoli nyomtatók engedélyezése"), ("Downloading {}", "{} letöltése"), diff --git a/src/lang/id.rs b/src/lang/id.rs index 25c12040d..8c7af75e4 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Menggabungkan tangkapan layar dari beberapa tampilan saat ini tidak didukung. Silakan beralih ke satu tampilan dan coba lagi."), ("screenshot-action-tip", "Silakan pilih cara melanjutkan dengan tangkapan layar."), ("Save as", "Simpan sebagai"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Salin ke papan klip"), ("Enable remote printer", "Aktifkan printer jarak jauh"), ("Downloading {}", "Mendownload {}"), diff --git a/src/lang/it.rs b/src/lang/it.rs index 330e5577a..8de645fbe 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "L'unione della cattura di schermate di più display non è attualmente supportata.\nPassa ad un singolo display e riprova."), ("screenshot-action-tip", "Seleziona come continuare con la schermata."), ("Save as", "Salva come"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copia negli appunti"), ("Enable remote printer", "Abilita stampante remota"), ("Downloading {}", "Download {}"), diff --git a/src/lang/ja.rs b/src/lang/ja.rs index f9ae7777e..71713ca01 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "複数のディスプレイのスクリーンショットの結合は、現在非対応です。単一のディスプレイに切り替えてもう一度お試しください。"), ("screenshot-action-tip", "スクリーンショットを続行する方法を選択してください。"), ("Save as", "保存先"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "クリップボードにコピー"), ("Enable remote printer", "リモートプリンターを有効化する"), ("Downloading {}", "{} をダウンロード中"), diff --git a/src/lang/ko.rs b/src/lang/ko.rs index f7da53b3f..abd48fb9e 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "현재 다중 디스플레이의 스크린샷 병합이 지원되지 않습니다. 단일 디스플레이로 전환한 후 다시 시도해 주세요."), ("screenshot-action-tip", "스크린샷을 계속 진행할 방법을 선택해 주세요."), ("Save as", "다른 이름으로 저장"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "클립보드에 복사"), ("Enable remote printer", "원격 프린터 허용"), ("Downloading {}", "{} 다운로드 중"), diff --git a/src/lang/kz.rs b/src/lang/kz.rs index 89121acca..b623e5c33 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Бірнеше дисплейдің скриншоттарын біріктіруге қазір қолдау көрсетілмейді. Жеке дисплейге ауысып, қайталап көруді өтінеміз."), ("screenshot-action-tip", "Скриншотпен қалай жалғастыру керектігін таңдауды өтінеміз."), ("Save as", "Басқаша сақтау"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Көшіру-тақтаға көшіру"), ("Enable remote printer", "Қашықтағы принтерді іске қосу"), ("Downloading {}", "{} жүктелуде"), diff --git a/src/lang/lt.rs b/src/lang/lt.rs index eb19f21c2..45d2ddc08 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Kelių ekranų nuotraukų sujungimas šiuo metu nepalaikomas. Perjunkite į vieną ekraną ir bandykite dar kartą."), ("screenshot-action-tip", "Pasirinkite, ką daryti su ekrano nuotrauka."), ("Save as", "Įrašyti kaip"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopijuoti į iškarpinę"), ("Enable remote printer", "Įgalinti nuotolinį spausdintuvą"), ("Downloading {}", "Atsisiunčiama {}"), diff --git a/src/lang/lv.rs b/src/lang/lv.rs index fe853cdff..a71cdc038 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Vairāku displeju ekrānuzņēmumu apvienošana pašlaik netiek atbalstīta. Lūdzu, pārslēdzieties uz vienu displeju un mēģiniet vēlreiz."), ("screenshot-action-tip", "Lūdzu, atlasiet, kā turpināt darbu ar ekrānuzņēmumu."), ("Save as", "Saglabāt kā"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopēt starpliktuvē"), ("Enable remote printer", "Iespējot attālo printeri"), ("Downloading {}", "Notiek {} lejupielāde"), diff --git a/src/lang/ml.rs b/src/lang/ml.rs index fe8534a0e..157a7abb3 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "മെർജ് ചെയ്ത സ്ക്രീൻഷോട്ട് പിന്തുണയ്ക്കുന്നില്ല."), ("screenshot-action-tip", "സ്ക്രീൻഷോട്ടിന് ശേഷമുള്ള നടപടി"), ("Save as", "പേരിൽ സേവ് ചെയ്യുക"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "ക്ലിപ്പ്ബോർഡിലേക്ക് കോപ്പി ചെയ്യുക"), ("Enable remote printer", "റിമോട്ട് പ്രിന്റർ അനുവദിക്കുക"), ("Downloading {}", "{} ഡൗൺലോഡ് ചെയ്യുന്നു"), diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 45bd5c540..e92c47eb2 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Sammenslåing av skjermbilder fra flere skjermer støttes for øyeblikket ikke. Bytt til én enkelt skjerm og prøv igjen."), ("screenshot-action-tip", "Velg hvordan du vil fortsette med skjermbildet."), ("Save as", "Lagre som"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopier til utklipstavlen"), ("Enable remote printer", "Aktiver fjernskriver"), ("Downloading {}", "Laster ned {}"), diff --git a/src/lang/nl.rs b/src/lang/nl.rs index b0d21f97f..813bb2110 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Schermopnames van meerdere schermen samenvoegen wordt momenteel niet ondersteund. Schakel over naar een enkel scherm en herhaal de actie."), ("screenshot-action-tip", "Kies wat je met de gemaakte schermopname wilt doen."), ("Save as", "Opslaan als"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiëren naar het klembord"), ("Enable remote printer", "Printer op afstand inschakelen"), ("Downloading {}", "Downloaden {}"), diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 120183803..37144a0bc 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Łączenie zrzutów ekranu z wielu wyświetlaczy nie jest obecnie obsługiwane. Przełącz się na pojedynczy wyświetlacz i spróbuj ponownie."), ("screenshot-action-tip", "Wybierz sposób kontynuacji zrzutu ekranu."), ("Save as", "Zapisz jako"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiuj do schowka"), ("Enable remote printer", "Włącz zdalne drukowanie"), ("Downloading {}", "Pobieranie {}"), diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index 7d033b363..94043d38d 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "A junção de capturas de ecrã de vários ecrãs não é atualmente suportada. Mude para um único ecrã e tente novamente."), ("screenshot-action-tip", "Selecione como pretende continuar com a captura de ecrã."), ("Save as", "Guardar como"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copiar para a área de transferência"), ("Enable remote printer", "Ativar impressora remota"), ("Downloading {}", "A transferir {}"), diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 897ef1735..a7879960d 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "A captura de tela de múltiplas telas não é suportada no momento. Por favor, alterne para uma única tela e tente novamente."), ("screenshot-action-tip", "Por favor, selecione como deseja continuar com a captura de tela."), ("Save as", "Salvar como"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copiar para área de transferência"), ("Enable remote printer", "Habilitar impressora remota"), ("Downloading {}", "Baixando {}"), diff --git a/src/lang/ro.rs b/src/lang/ro.rs index aee37cf94..03eb282f5 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Captura de ecran a ecranului combinat nu este suportată în prezent."), ("screenshot-action-tip", "Selectează acțiunea pentru captura de ecran: salvează ca fișier sau copiază în clipboard."), ("Save as", "Salvează ca"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copiază în clipboard"), ("Enable remote printer", "Activează imprimanta la distanță"), ("Downloading {}", "Se descarcă {}"), diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 834fcd565..8d29105db 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Объединение снимков экранов с нескольких дисплеев в настоящее время не поддерживается. Переключитесь на один дисплей и повторите действие."), ("screenshot-action-tip", "Выберите, что делать с полученным снимком экрана."), ("Save as", "Сохранить в файл"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Копировать в буфер обмена"), ("Enable remote printer", "Использовать удалённый принтер"), ("Downloading {}", "Скачивание"), diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 59d0967c6..8be034c4b 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "S'unione de sa catura de ischermadas de prus ischermos como no est suportada.\nCola a un'ischermu ebbia e torra a proare."), ("screenshot-action-tip", "Seletziona comente sighire cun s'ischermada."), ("Save as", "Sarva comente"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Còpia in punta de billete"), ("Enable remote printer", "Abìlita imprentadora remota"), ("Downloading {}", "Iscarrighende {}"), diff --git a/src/lang/sk.rs b/src/lang/sk.rs index f01cf6e3a..cf17d2129 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Zlučovanie snímok obrazovky z viacerých displejov nie je momentálne podporované. Prepnite na jeden displej a skúste to znova."), ("screenshot-action-tip", "Vyberte, ako pokračovať so snímkou obrazovky."), ("Save as", "Uložiť ako"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopírovať do schránky"), ("Enable remote printer", "Povoliť vzdialenú tlačiareň"), ("Downloading {}", "Sťahuje sa {}"), diff --git a/src/lang/sl.rs b/src/lang/sl.rs index 04a0dd0e2..6d4480fe5 100644 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Združevanje posnetkov zaslona z več zaslonov trenutno ni podprto. Preklopite na en zaslon in poskusite znova."), ("screenshot-action-tip", "Izberite, kako nadaljevati s posnetkom zaslona."), ("Save as", "Shrani kot"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiraj v odložišče"), ("Enable remote printer", "Omogoči oddaljeni tiskalnik"), ("Downloading {}", "Prenašanje {}"), diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 2fb1c811d..470708082 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Bashkimi i pamjeve të ekranit nga disa ekrane aktualisht nuk mbështetet. Ju lutemi kaloni te një ekran i vetëm dhe provoni përsëri."), ("screenshot-action-tip", "Ju lutemi zgjidhni si të vazhdoni me pamjen e ekranit."), ("Save as", "Ruaj si"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopjo te clipboard"), ("Enable remote printer", "Aktivizo printerin në distancë"), ("Downloading {}", "Duke shkarkuar {}"), diff --git a/src/lang/sr.rs b/src/lang/sr.rs index e1b0e703d..fe3d047e5 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Spajanje snimaka ekrana sa više prikaza trenutno nije podržano. Molimo prebacite na jedan prikaz i pokušajte ponovo."), ("screenshot-action-tip", "Molimo izaberite kako da nastavite sa snimkom ekrana."), ("Save as", "Sačuvaj kao"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiraj u clipboard"), ("Enable remote printer", "Omogući udaljeni štampač"), ("Downloading {}", "Preuzimanje {}"), diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 594efa688..9f2efc263 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Sammanslagning av skärmdumpar från flera skärmar stöds för närvarande inte. Byt till en enda skärm och försök igen."), ("screenshot-action-tip", "Välj hur du vill fortsätta med skärmdumpen."), ("Save as", "Spara som"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kppiera till urklipp"), ("Enable remote printer", "Aktivera fjärrskrivare"), ("Downloading {}", "Laddar ner {}"), diff --git a/src/lang/ta.rs b/src/lang/ta.rs index 8a4afde95..ac2486ccb 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "ஸ்கிரீன்ஷாட்_இணைக்கப்பட்ட_திரை_ஆதரவற்ற_குறிப்பு"), ("screenshot-action-tip", "ஸ்கிரீன்ஷாட்_செயல்_குறிப்பு"), ("Save as", "இப்படி சேமி"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "கிளிப்போர்டில் நகல்"), ("Enable remote printer", "தொலை அச்சுப்பொறி இயக்கு"), ("Downloading {}", "{} பதிவிறக்குகிறது"), diff --git a/src/lang/template.rs b/src/lang/template.rs index 83497a0f6..b65c92793 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", ""), ("screenshot-action-tip", ""), ("Save as", ""), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", ""), ("Enable remote printer", ""), ("Downloading {}", ""), diff --git a/src/lang/th.rs b/src/lang/th.rs index 31f314726..7531d072d 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "ขณะนี้ยังไม่รองรับการรวมภาพหน้าจอจากหลายจอแสดงผล กรุณาสลับไปใช้จอแสดงผลเดียวแล้วลองใหม่"), ("screenshot-action-tip", "กรุณาเลือกวิธีดำเนินการต่อกับภาพหน้าจอ"), ("Save as", "บันทึกเป็น"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "คัดลอกไปยังคลิปบอร์ด"), ("Enable remote printer", "เปิดใช้งานเครื่องพิมพ์ระยะไกล"), ("Downloading {}", "กำลังดาวน์โหลด {}"), diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 66ac42a1c..8546d96bc 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Birden fazla ekranın ekran görüntülerinin birleştirilmesi şu anda desteklenmiyor. Lütfen tek bir ekrana geçin ve tekrar deneyin."), ("screenshot-action-tip", "Lütfen ekran görüntüsüyle nasıl devam edeceğinizi seçin."), ("Save as", "Farklı kaydet"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Panoya kopyala"), ("Enable remote printer", "Uzak yazıcıyı etkinleştir"), ("Downloading {}", "{} indiriliyor"), diff --git a/src/lang/tw.rs b/src/lang/tw.rs index b35322d10..8663c5d5f 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "目前不支援合併多個螢幕的截圖。請切換至單一螢幕後再試。"), ("screenshot-action-tip", "請選擇要如何處理這張截圖。"), ("Save as", "另存為"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "複製到剪貼簿"), ("Enable remote printer", "啟用遠端列印"), ("Downloading {}", "正在下載 {} 並安裝新版本。"), diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 281b9cd6c..c11eac1f6 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Об'єднання знімків кількох дисплеїв наразі не підтримується. Перейдіть на один дисплей і спробуйте знову."), ("screenshot-action-tip", "Виберіть, що робити зі знімком екрана."), ("Save as", "Зберегти як"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Скопіювати до буфера обміну"), ("Enable remote printer", "Увімкнути віддалений принтер"), ("Downloading {}", "Завантаження {}"), diff --git a/src/lang/vi.rs b/src/lang/vi.rs index c9b28b949..7f4e0ef46 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Không hỗ trợ chụp gộp nhiều màn hình."), ("screenshot-action-tip", "Hành động chụp màn hình"), ("Save as", "Lưu thành"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Sao chép vào Clipboard"), ("Enable remote printer", "Bật máy in từ xa"), ("Downloading {}", "Đang tải xuống {}"), diff --git a/src/server/connection.rs b/src/server/connection.rs index adcab4c88..bcdae795a 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -2021,11 +2021,17 @@ impl Connection { self.update_scoped_login_options().await; if let Some((dir, show_hidden)) = self.file_transfer.clone() { self.keyboard = false; - let dir = if !dir.is_empty() && std::path::Path::new(&dir).is_dir() { - &dir - } else { - "" - }; + let is_existing_dir = !dir.is_empty() && std::path::Path::new(&dir).is_dir(); + let is_allowed_dir = + is_existing_dir && crate::common::is_peer_path_allowed(&dir, false); + #[cfg(target_os = "android")] + if is_existing_dir && !is_allowed_dir { + log::warn!( + "Use the app workspace because the initial file-transfer directory is outside it: {}", + dir + ); + } + let dir = if is_allowed_dir { &dir } else { "" }; if !wait_session_id_confirm { self.read_dir(dir, show_hidden); } else { @@ -3313,6 +3319,81 @@ impl Connection { return true; } } + // Android is scoped-storage only: reject any peer supplied path that + // escapes the app workspace before it reaches the filesystem. + #[cfg(target_os = "android")] + { + // (path, job id, allow empty) of the peer supplied path this action + // operates on. + let checked: Option<(&str, i32, bool)> = match &fa.union { + Some(file_action::Union::ReadEmptyDirs(rd)) => { + Some((rd.path.as_str(), -1, false)) + } + Some(file_action::Union::ReadDir(rd)) => { + Some((rd.path.as_str(), 0, true)) + } + Some(file_action::Union::AllFiles(f)) => { + Some((f.path.as_str(), f.id, false)) + } + Some(file_action::Union::Send(s)) => { + // Printer jobs read from memory, `path` is only a lookup key. + if JobType::from_proto(s.file_type) == JobType::Generic { + Some((s.path.as_str(), s.id, false)) + } else { + None + } + } + Some(file_action::Union::Receive(r)) => { + Some((r.path.as_str(), r.id, false)) + } + Some(file_action::Union::RemoveDir(d)) => { + Some((d.path.as_str(), d.id, false)) + } + Some(file_action::Union::RemoveFile(f)) => { + Some((f.path.as_str(), f.id, false)) + } + Some(file_action::Union::Create(c)) => { + Some((c.path.as_str(), c.id, false)) + } + Some(file_action::Union::Rename(r)) => { + Some((r.path.as_str(), r.id, false)) + } + _ => None, + }; + if let Some((path, job_id, allow_empty)) = checked { + if !crate::common::is_peer_path_allowed(path, allow_empty) { + log::warn!( + "Reject file action outside the app workspace: {}", + path + ); + if job_id >= 0 { + self.send(fs::new_error(job_id, "Permission denied", -1)) + .await; + } + return true; + } + } + if let Some(file_action::Union::Rename(r)) = &fa.union { + let destination = std::path::Path::new(&r.path) + .parent() + .map(|parent| parent.join(&r.new_name)); + let allowed = destination + .as_deref() + .and_then(std::path::Path::to_str) + .map_or(false, |path| { + crate::common::is_peer_path_allowed(path, false) + }); + if !allowed { + log::warn!( + "Reject rename destination outside the app workspace: {:?}", + destination + ); + self.send(fs::new_error(r.id, "Permission denied", -1)) + .await; + return true; + } + } + } match fa.union { Some(file_action::Union::ReadEmptyDirs(rd)) => { self.read_empty_dirs(&rd.path, rd.include_hidden); diff --git a/src/ui_cm_interface.rs b/src/ui_cm_interface.rs index 5e13ef82b..c659170e3 100644 --- a/src/ui_cm_interface.rs +++ b/src/ui_cm_interface.rs @@ -977,6 +977,61 @@ async fn handle_fs( tx_log: Option<&UnboundedSender>, _conn_id: i32, ) { + // Android is scoped-storage only, so every peer supplied path has to stay inside the + // app workspace. This is the filesystem boundary, keep it enforced here even though + // `Connection` rejects out-of-workspace requests earlier as well. + #[cfg(target_os = "android")] + { + // (path, job id, file num, allow empty) of the peer supplied path this message + // acts on. + let checked: Option<(&str, i32, i32, bool)> = match &fs { + ipc::FS::ReadEmptyDirs { dir, .. } => Some((dir.as_str(), -1, -1, false)), + ipc::FS::ReadDir { dir, .. } => Some((dir.as_str(), -1, -1, true)), + ipc::FS::RemoveDir { path, id, .. } | ipc::FS::CreateDir { path, id } => { + Some((path.as_str(), *id, 0, false)) + } + ipc::FS::Rename { path, id, .. } => Some((path.as_str(), *id, 0, false)), + ipc::FS::RemoveFile { path, id, file_num } => { + Some((path.as_str(), *id, *file_num, false)) + } + ipc::FS::ReadAllFiles { path, id, .. } => Some((path.as_str(), *id, -1, false)), + ipc::FS::NewWrite { + path, id, file_num, .. + } + | ipc::FS::ReadFile { + path, id, file_num, .. + } => Some((path.as_str(), *id, *file_num, false)), + _ => None, + }; + if let Some((path, id, file_num, allow_empty)) = checked { + if !crate::common::is_peer_path_allowed(path, allow_empty) { + log::warn!("Reject file operation outside the app workspace: {}", path); + if id >= 0 { + send_raw(fs::new_error(id, "Permission denied", file_num), tx); + } + return; + } + } + if let ipc::FS::Rename { path, new_name, id } = &fs { + let destination = std::path::Path::new(path) + .parent() + .map(|parent| parent.join(new_name)); + let allowed = destination + .as_deref() + .and_then(std::path::Path::to_str) + .map_or(false, |path| { + crate::common::is_peer_path_allowed(path, false) + }); + if !allowed { + log::warn!( + "Reject rename destination outside the app workspace: {:?}", + destination + ); + send_raw(fs::new_error(*id, "Permission denied", 0), tx); + return; + } + } + } match fs { ipc::FS::ReadEmptyDirs { dir, From 169f74f8d90260b2083f3587ca0eba34ef30af4d Mon Sep 17 00:00:00 2001 From: rustdesk Date: Mon, 31 Aug 2026 17:12:14 +0800 Subject: [PATCH 67/72] fix(ci): check out submodules in update-webpki-roots The root workspace lists libs/hbb_common as a member, so without the submodule cargo cannot load the workspace and `cargo update` exits 101. The job has failed on every scheduled run since it was added. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Gecc6fgEeSxs6VRiQmAeof --- .github/workflows/update-webpki-roots.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/update-webpki-roots.yml b/.github/workflows/update-webpki-roots.yml index e1efdb0d6..bf3150653 100644 --- a/.github/workflows/update-webpki-roots.yml +++ b/.github/workflows/update-webpki-roots.yml @@ -33,6 +33,10 @@ jobs: steps: - name: Checkout source code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + # The root workspace lists libs/hbb_common as a member; without the + # submodule its manifest is missing and cargo cannot load the workspace. + submodules: recursive - name: Update webpki-roots in all lockfiles id: update From 66ab0b87f696182b9fce5403fe48bdcab00d3ba1 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:51:27 +0800 Subject: [PATCH 68/72] Linux drop shell from service loop (#15979) * perf(linux): stop the service loop from forking a shell per environment variable The service loop re-derives the desktop every 500 ms, and every lookup on that path forks. A healthy GNOME session spends ~104 process spawns a second, 8 full `ps -u ` scans and 2 full `ps aux` scans, to re-answer a question whose answer has not changed. `get_env` alone is a `sh -c` pipeline of ~12 processes per variable. `get_envs` already reads `/proc` directly and was documented as the intended replacement, so move the remaining `get_env` callers to it and delete it. The xwayland probe drops from 4 pipelines (~48 processes) to one `/proc` walk, and the pathological walk that #15952 was about drops from ~2900 processes to at most 60 `/proc` walks. `get_cm` and `is_xwayland_running` read `/proc` instead of forking `ps aux` and `pgrep -a`; `get_cm` also called `current_exe()` once per line of `ps` output. Selection semantics are preserved where they were load-bearing: * `get_envs_of_newest` reproduces the `ps ... | tail -1` the removed pipelines used, so a variable the newest matching process does not have means moving on to the next pattern, never on to an older process that may belong to a session which has since logged out. * `get_envs` keeps its own order (readdir) and its all-process ranking, so the existing `get_display_xauth_wayland` caller is unaffected. Only its handling of an exported-but-empty value changes: `DISPLAY=` no longer counts as found, where it used to satisfy a single-name query and return the empty value before a process holding a real one was examined. * `get_envs_where` lets the caller state what a complete answer is. Ranking by how many of the requested names a process carries cannot know that `DISPLAY` is mandatory and the rest interchangeable, so it could rank a process holding three optional values above the one holding the pair that matters. `is_xwayland_running` is scoped to the session's uid. The compositor starts Xwayland as the session user, so another user's Xwayland -- a switched-away session, a second seat -- used to route a pure-Wayland session into the Xwayland probe, which has no display for it to find there. Not addressed: this discovery path has never had any notion of the active session, and filters by uid alone. Constraining candidates to the active session is not possible for the most important one, since `xdg-desktop-portal` and its backends run under `user@.service`, which spans sessions and carries no `XDG_SESSION_ID`, no session cgroup and no audit sessionid. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q5egQpH4q4GoXJiuMoTJ5t * fix(linux): the newest-process walk must not answer with a grep or an older PID Three findings from review of the commit before this one. `/proc//environ` failing to read left the walk on to the next PID, which in `newest_first` mode is an older process -- possibly of a session that has since logged out -- where the `ps ... | tail -1` pipeline this replaces stopped at the one PID it had already picked. A read that fails is a process carrying none of the requested names, not a process to skip. The `seen` latch that was meant to hold the newest process is deleted: `accept` is reached once per matching process, so returning on the first is what it already did. The regex is matched against the whole `/proc//cmdline`, where the pipeline had a `grep -v 'grep'`. A user running `grep Xwayland` is otherwise the newest match for that pattern and answers with whatever environment their shell had -- an X forwarding endpoint over ssh, say. This is the one place the walk still differs from the `get_envs` it grew out of, which never had that filter and could take an ssh `grep` over the portal it was looking for. `get_envs` is left exactly as it was. Its completeness test was every requested name *present*; stating it through `accept` turned it into every name *non-empty* and, with the empty-value change that went with it, moved which process the existing `get_display_xauth_wayland` caller settles on. `accept` is now told the count and asks the question the loop it replaced asked. This supersedes the `get_envs` bullet of the previous commit message: an exported-but-empty value counts as found again, as it always did. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019QgsYAUYKDei1AM5yHJsMX --------- Co-authored-by: Claude Opus 5 (1M context) --- src/platform/linux.rs | 225 +++++++++++++++++++++++++++--------------- 1 file changed, 145 insertions(+), 80 deletions(-) diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 247f70b01..099d00a2d 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1254,20 +1254,15 @@ pub fn get_active_userid_cached() -> Option { } fn get_cm() -> bool { - // We use `CMD_PS` instead of `ps` to suppress some audit messages on some systems. - if let Ok(output) = Command::new(CMD_PS.as_str()).args(vec!["aux"]).output() { - for line in String::from_utf8_lossy(&output.stdout).lines() { - if line.contains(&format!( - "{} --cm", - std::env::current_exe() - .unwrap_or("".into()) - .to_string_lossy() - )) { - return true; - } - } - } - false + // Runs twice a second in the service loop, so walk /proc rather than forking `ps aux`; that + // fork is also what the `CMD_PS` audit-message workaround this replaces was for. + let cm = format!( + "{} --cm", + std::env::current_exe() + .unwrap_or_default() + .to_string_lossy() + ); + any_process(None, "cmdline", |cmdline| cmdline.contains(&cm)) } pub fn is_login_wayland() -> bool { @@ -1576,6 +1571,34 @@ fn get_envs<'a>( process_pat: &str, names: &[&'a str], ) -> std::collections::HashMap<&'a str, String> { + get_envs_where(uid, process_pat, names, false, |count| count == names.len()) +} + +/// The newest process matching `process_pat`, whatever it happens to carry: the semantics of the +/// `ps -u -f | grep | tail -1` pipeline the callers below used before. A variable this +/// process does not have means moving on to the next pattern, never on to an older process that +/// may belong to a session which has since logged out. +fn get_envs_of_newest<'a>( + uid: &str, + process_pat: &str, + names: &[&'a str], +) -> std::collections::HashMap<&'a str, String> { + get_envs_where(uid, process_pat, names, true, |_| true) +} + +/// `get_envs` with the caller's own process order and its own notion of a complete answer, told +/// how many of `names` the process carries: the first process `accept` takes wins outright, and +/// the count-based ranking is only the fallback for when no process is accepted at all. +fn get_envs_where<'a, F>( + uid: &str, + process_pat: &str, + names: &[&'a str], + newest_first: bool, + mut accept: F, +) -> std::collections::HashMap<&'a str, String> +where + F: FnMut(usize) -> bool, +{ // The tie-breaking logic uses a u64 bitmask, limiting us to 64 variables. debug_assert!( names.len() <= 64, @@ -1602,21 +1625,24 @@ fn get_envs<'a>( let mut best_count = 0usize; let mut best_mask: u64 = 0; - // Iterate /proc to find matching processes + // Iterate /proc to find matching processes. `newest_first` is only for `get_envs_of_newest`, + // whose callers need the last PID-ordered match their `ps ... | tail -1` pipelines took; + // without it the order is whatever readdir returns, which is what `get_envs` has always used. + // Neither order identifies the active session -- a user with two live graphical sessions has + // one of each, and picking by PID guesses. See `Desktop::refresh` for who owns that question. let Ok(entries) = std::fs::read_dir("/proc") else { return best; }; + let mut pids: Vec = entries + .flatten() + .filter_map(|entry| entry.file_name().to_str()?.parse::().ok()) + .collect(); + if newest_first { + pids.sort_unstable_by(|a, b| b.cmp(a)); + } - for entry in entries.flatten() { - let file_name = entry.file_name(); - let Some(pid_str) = file_name.to_str() else { - continue; - }; - if !pid_str.chars().all(|c| c.is_ascii_digit()) { - continue; - } - - let proc_path = entry.path(); + for pid in pids { + let proc_path = std::path::Path::new("/proc").join(pid.to_string()); // Check if process belongs to the specified uid if let Ok(meta) = std::fs::metadata(&proc_path) { @@ -1634,15 +1660,18 @@ fn get_envs<'a>( continue; }; let cmdline_str = String::from_utf8_lossy(&cmdline).replace('\0', " "); - if !re.is_match(&cmdline_str) { + // The `grep -v 'grep'` of the pipeline this replaces. A user grepping for one of these + // patterns is otherwise the newest match for it, and answers with whatever environment + // their shell had -- an X forwarding endpoint over ssh, say. + if cmdline_str.contains("grep") || !re.is_match(&cmdline_str) { continue; } - // Read environ and extract matching variables - let environ_path = proc_path.join("environ"); - let Ok(environ) = std::fs::read(&environ_path) else { - continue; - }; + // Read environ and extract matching variables. A read that fails -- the process exited + // between these two reads -- is a process carrying none of `names`, not a process to + // skip: skipping it would hand `newest_first` on to an older PID, where the pipeline + // this replaces stopped at the single PID its `tail -1` had already picked. + let environ = std::fs::read(proc_path.join("environ")).unwrap_or_default(); let mut found = empty.clone(); let mut found_count = 0usize; @@ -1673,14 +1702,14 @@ fn get_envs<'a>( found_mask |= bit; } } - - if found_count == names.len() { - return found; - } } } } + if accept(found_count) { + return found; + } + if found_count > best_count || (found_count == best_count && found_mask > best_mask) { best = found; best_count = found_count; @@ -1691,29 +1720,37 @@ fn get_envs<'a>( best } -/// Deprecated: Use `get_envs` instead. -/// -/// https://github.com/rustdesk/rustdesk/discussions/11959 -/// -/// **Note**: This function is retained for conservative migration. The plan is to gradually -/// transition all callers to `get_envs` after it proves stable and reliable. Once `get_envs` -/// is confirmed to work correctly across all use cases, this function will be removed entirely. -/// -/// # Arguments -/// * `name` - Environment variable name to retrieve -/// * `uid` - User ID to filter processes -/// * `process` - Process name pattern to match -/// -/// # Returns -/// The environment variable value, or empty string if not found -#[inline] -fn get_env(name: &str, uid: &str, process: &str) -> String { - let cmd = format!("ps -u {} -f | grep -E '{}' | grep -v 'grep' | tail -1 | awk '{{print $2}}' | xargs -I__ cat /proc/__/environ 2>/dev/null | tr '\\0' '\\n' | grep '^{}=' | tail -1 | sed 's/{}=//g'", uid, process, name, name); - if let Ok(x) = run_cmds(&cmd) { - x.trim_end().to_string() - } else { - "".to_owned() +/// True when `pred` accepts the `/proc//` of any process, NULs turned into spaces, +/// optionally only of processes owned by `uid`. +/// Reads `/proc` directly instead of forking `ps` / `pgrep`, for the service-loop callers below. +fn any_process bool>(uid: Option, file: &str, pred: F) -> bool { + let Ok(entries) = std::fs::read_dir("/proc") else { + return false; + }; + for entry in entries.flatten() { + let file_name = entry.file_name(); + let Some(pid_str) = file_name.to_str() else { + continue; + }; + if !pid_str.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let proc_path = entry.path(); + if let Some(uid) = uid { + use std::os::unix::fs::MetadataExt; + match std::fs::metadata(&proc_path) { + Ok(meta) if meta.uid() == uid => {} + _ => continue, + } + } + let Ok(content) = std::fs::read(proc_path.join(file)) else { + continue; + }; + if pred(&String::from_utf8_lossy(&content).replace('\0', " ")) { + return true; + } } + false } #[inline] @@ -1931,12 +1968,16 @@ pub fn change_resolution_directly(name: &str, width: usize, height: usize) -> Re Ok(()) } +/// Scoped to `uid`, the user of the session being refreshed: the compositor starts Xwayland as +/// that user, so another user's Xwayland -- a switched-away session, a second seat -- answering +/// this used to route a pure-Wayland session into the Xwayland probe, which has no display for +/// it to find. A uid that cannot be parsed falls back to the unscoped answer. #[inline] -pub fn is_xwayland_running() -> bool { - if let Ok(output) = run_cmds("pgrep -a Xwayland") { - return output.contains("Xwayland"); - } - false +pub fn is_xwayland_running(uid: &str) -> bool { + // Same test as the `pgrep -a Xwayland` this replaces: the process name, not its command line. + any_process(uid.parse::().ok(), "comm", |comm| { + comm.contains("Xwayland") + }) } mod desktop { @@ -1959,10 +2000,14 @@ mod desktop { /// A compositor that runs Xwayland without exporting `XAUTHORITY` (wlroots, e.g. Hyprland) /// still hands out a usable session through the Wayland side. Requiring xauth there never - /// succeeded, so every refresh ran the retry loop to the end, 240 shell pipelines at a time. + /// succeeded, so every refresh ran the retry loop to the end. /// https://github.com/rustdesk/rustdesk/issues/15952 - fn is_session_env_complete(display: &str, xauth: &str, wl_display: &str, dbus: &str) -> bool { - !display.is_empty() && (!xauth.is_empty() || (!wl_display.is_empty() && !dbus.is_empty())) + fn is_session_env_complete(envs: &std::collections::HashMap<&str, String>) -> bool { + let value = |key: &str| envs.get(key).map_or("", |v| v.as_str()); + !value(ENV_KEY_DISPLAY).is_empty() + && (!value(ENV_KEY_XAUTHORITY).is_empty() + || (!value(ENV_KEY_WAYLAND_DISPLAY).is_empty() + && !value(ENV_KEY_DBUS_SESSION_BUS_ADDRESS).is_empty())) } #[derive(Debug, Clone, Default)] @@ -2037,17 +2082,33 @@ mod desktop { self.dbus.clear(); let mut kept = 0u8; for proc in display_proc { - let display = get_env(ENV_KEY_DISPLAY, &self.uid, proc); - let xauth = get_env(ENV_KEY_XAUTHORITY, &self.uid, proc); - let wl_display = get_env(ENV_KEY_WAYLAND_DISPLAY, &self.uid, proc); - let dbus = get_env(ENV_KEY_DBUS_SESSION_BUS_ADDRESS, &self.uid, proc); - // Take a candidate whole and keep the best seen. Assigning each variable - // unconditionally let a pattern that does not run on this desktop blank out - // the values an earlier one had answered with, which is how a session with a - // working portal ended up starting its `--server` with no compositor and no - // bus at all. The Wayland-only rank is what a session whose Xwayland exports - // no `XAUTHORITY` can still offer. - let complete = is_session_env_complete(&display, &xauth, &wl_display, &dbus); + let mut envs = get_envs_of_newest( + &self.uid, + proc, + &[ + ENV_KEY_DISPLAY, + ENV_KEY_XAUTHORITY, + ENV_KEY_WAYLAND_DISPLAY, + ENV_KEY_DBUS_SESSION_BUS_ADDRESS, + ], + ); + let complete = is_session_env_complete(&envs); + let display = envs.remove(ENV_KEY_DISPLAY).unwrap_or_default(); + let xauth = envs.remove(ENV_KEY_XAUTHORITY).unwrap_or_default(); + let wl_display = envs.remove(ENV_KEY_WAYLAND_DISPLAY).unwrap_or_default(); + let dbus = envs + .remove(ENV_KEY_DBUS_SESSION_BUS_ADDRESS) + .unwrap_or_default(); + // Take a candidate whole. Two graphical sessions of one user each answer + // some of these, and a display paired with another session's xauth or + // compositor is a pair that never existed. So rank candidates rather than + // merge them, and keep the best seen: the later patterns are fallbacks. + // + // The Wayland-only rank matters when `is_xwayland_running` matched some other + // user's Xwayland and this session has none of its own. Nothing here can then + // answer with a display, and dropping the candidate for that would leave the + // child server without the compositor and bus of a session that is perfectly + // serveable through them. let rank = if complete { 3 } else if !wl_display.is_empty() && !dbus.is_empty() { @@ -2091,7 +2152,9 @@ mod desktop { SDDM_GREETER, ]; for proc in display_proc { - self.display = get_env(ENV_KEY_DISPLAY, &self.uid, proc); + self.display = get_envs_of_newest(&self.uid, proc, &[ENV_KEY_DISPLAY]) + .remove(ENV_KEY_DISPLAY) + .unwrap_or_default(); if !self.display.is_empty() { break; } @@ -2222,7 +2285,9 @@ mod desktop { tray.as_str(), ]; for proc in display_proc { - self.xauth = get_env("XAUTHORITY", &self.uid, proc); + self.xauth = get_envs_of_newest(&self.uid, proc, &[ENV_KEY_XAUTHORITY]) + .remove(ENV_KEY_XAUTHORITY) + .unwrap_or_default(); if !self.xauth.is_empty() { break; } @@ -2308,7 +2373,7 @@ mod desktop { pub fn refresh(&mut self) { if !self.sid.is_empty() && is_active_and_seat0(&self.sid) { // Xwayland display and xauth may not be available in a short time after login. - if is_xwayland_running() && !self.is_login_wayland() { + if is_xwayland_running(&self.uid) && !self.is_login_wayland() { self.get_display_xauth_xwayland(); } else if self.is_wayland() { self.get_display_xauth_wayland(); @@ -2350,7 +2415,7 @@ mod desktop { self.get_home(); if self.is_wayland() { - if is_xwayland_running() { + if is_xwayland_running(&self.uid) { self.get_display_xauth_xwayland(); } else { self.get_display_xauth_wayland(); From 2c84c8fb1333bfbc3c4850d460a07ab75711ecf0 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Mon, 31 Aug 2026 19:06:32 +0800 Subject: [PATCH 69/72] change to 3.44.9 flutter for arm --- .github/workflows/flutter-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 759677cca..1b0a6424a 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -31,7 +31,7 @@ env: # engine is 3.44. Every other platform stays on FLUTTER_VERSION (3.24.5) until Windows 7 # support is restored after the upstream-wide Flutter bump. The arm64 job patches the few # 3.44-only source/pubspec changes on the fly (see "Patch RustDesk sources for Flutter 3.44"). - FLUTTER_WINDOWS_ARM_VERSION: "3.44.8" + FLUTTER_WINDOWS_ARM_VERSION: "3.44.9" # for arm64 linux because official Dart SDK does not work FLUTTER_ELINUX_VERSION: "3.16.9" TAG_NAME: "${{ inputs.upload-tag }}" From 1ec1b9e7e3808b24bfc923ee1e6800ab9694e9a5 Mon Sep 17 00:00:00 2001 From: Michael Clark <104532890+michaeljclarkk@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:29:51 +1000 Subject: [PATCH 70/72] fix: android: target API 36 (#15603) * fix: android: target API 35 Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: handle API 35 foreground service types Integrate the foreground-service and MediaProjection lifecycle changes from fufesou/rustdesk#68 while leaving storage permission handling to #15602. Co-authored-by: fufesou Signed-off-by: fufesou Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: bump required android sdk version to 36, per recent google requirement change. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix(android): clear microphone FGS type when capture stops Signed-off-by: fufesou * fix(android): harden API 36 capture service lifecycle - isolate MediaProjection callbacks per session - keep foreground service types in sync with capture state - handle audio startup failures and shared frame ownership - upgrade AGP to 8.10.1 for API 36 support Signed-off-by: fufesou * fix(android): reset capture state on FGS update failure Signed-off-by: fufesou * fix(android): recover capture after projection failure Propagate virtual display startup failures, clean up partial video resources, and resume capture after media projection is reauthorized. Signed-off-by: fufesou * fix(android): preserve voice call during projection replacement Keep the existing capture active until a new projection is acquired, and restore the voice-call audio source when capture restarts. Signed-off-by: fufesou * fix(android): use JDK 17 in playground workflow Signed-off-by: fufesou * fix(android): clear pending capture restart on denial Notify MainService when a recovery projection request is canceled so a later projection grant cannot restart stale capture state. Signed-off-by: fufesou * fix(android): handle audio and projection recovery failures Verify AudioRecord startup, propagate voice-call restoration failures, and clear stale capture recovery state when projection setup fails. Signed-off-by: fufesou --------- Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> Signed-off-by: fufesou Co-authored-by: fufesou --- .github/workflows/playground.yml | 6 +- flutter/android/app/build.gradle | 7 +- .../android/app/src/main/AndroidManifest.xml | 9 +- .../carriez/flutter_hbb/AudioRecordHandle.kt | 142 +++++-- .../com/carriez/flutter_hbb/MainService.kt | 359 +++++++++++++++--- .../PermissionRequestTransparentActivity.kt | 11 +- .../kotlin/com/carriez/flutter_hbb/common.kt | 3 +- .../app/src/main/res/values/strings.xml | 1 + flutter/android/build.gradle | 36 ++ .../gradle/wrapper/gradle-wrapper.properties | 2 +- flutter/android/settings.gradle | 2 +- 11 files changed, 470 insertions(+), 108 deletions(-) diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml index f8e408f76..7dee9b83a 100644 --- a/.github/workflows/playground.yml +++ b/.github/workflows/playground.yml @@ -283,7 +283,7 @@ jobs: nasm \ yasm \ ninja-build \ - openjdk-11-jdk-headless \ + openjdk-17-jdk-headless \ pkg-config \ tree \ wget @@ -365,9 +365,9 @@ jobs: - name: Build rustdesk shell: bash env: - JAVA_HOME: /usr/lib/jvm/java-11-openjdk-amd64 + JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64 run: | - export PATH=/usr/lib/jvm/java-11-openjdk-amd64/bin:$PATH + export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH # temporary use debug sign config sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle case ${{ matrix.job.target }} in diff --git a/flutter/android/app/build.gradle b/flutter/android/app/build.gradle index 830cbc2dd..44eb32ca0 100644 --- a/flutter/android/app/build.gradle +++ b/flutter/android/app/build.gradle @@ -82,7 +82,8 @@ protobuf { } android { - compileSdkVersion 34 + namespace "com.carriez.flutter_hbb" + compileSdkVersion 36 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -91,6 +92,7 @@ android { } compileOptions { + coreLibraryDesugaringEnabled true targetCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8 } @@ -99,7 +101,7 @@ android { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.carriez.flutter_hbb" minSdkVersion 22 - targetSdkVersion 33 + targetSdkVersion 36 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } @@ -128,6 +130,7 @@ flutter { } dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4' implementation 'com.google.protobuf:protobuf-javalite:3.20.1' implementation "androidx.media:media:1.6.0" implementation 'com.github.getActivity:XXPermissions:18.5' diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index 2d9616a6c..e07881846 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,8 @@ + + @@ -89,7 +91,12 @@ + android:exported="false" + android:foregroundServiceType="specialUse|mediaProjection|microphone"> + + Boolean, private var isAudioStart: ()->Boolean) { - private val logTag = "LOG_AUDIO_RECORD_HANDLE" + companion object { + private const val LOG_TAG = "LOG_AUDIO_RECORD_HANDLE" + private const val NO_ACTIVE_PUBLISHERS = 0 + private var activeAudioFramePublishers = NO_ACTIVE_PUBLISHERS + + @Synchronized + private fun acquireAudioFramePublisher() { + if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) { + FFI.setFrameRawEnable("audio", true) + } + activeAudioFramePublishers++ + } + + @Synchronized + private fun releaseAudioFramePublisher() { + if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) { + Log.e(LOG_TAG, "No active audio frame publisher to release") + return + } + activeAudioFramePublishers-- + if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) { + FFI.setFrameRawEnable("audio", false) + } + } + } + + private val logTag = LOG_TAG private var audioRecorder: AudioRecord? = null private var audioReader: AudioReader? = null @@ -79,48 +105,94 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart: return } // read f32 to byte , length * 4 - minBufferSize = 2 * 4 * AudioRecord.getMinBufferSize( + val bufferSize = 2 * 4 * AudioRecord.getMinBufferSize( AUDIO_SAMPLE_RATE, AUDIO_CHANNEL_MASK, AUDIO_ENCODING ) - if (minBufferSize == 0) { + if (bufferSize <= 0) { Log.d(logTag, "get min buffer size fail!") return } - audioReader = AudioReader(minBufferSize, 4) + audioReader = AudioReader(bufferSize, 4) + minBufferSize = bufferSize Log.d(logTag, "init audioData len:$minBufferSize") } - @RequiresApi(Build.VERSION_CODES.M) - fun startAudioRecorder() { - checkAudioReader() - if (audioReader != null && audioRecorder != null && minBufferSize != 0) { - try { - FFI.setFrameRawEnable("audio", true) - audioRecorder!!.startRecording() - audioRecordStat = true - audioThread = thread { - while (audioRecordStat) { - audioReader!!.readSync(audioRecorder!!)?.let { - FFI.onAudioFrameUpdate(it) - } - } - // let's release here rather than onDestroy to avoid threading issue - audioRecorder?.release() - audioRecorder = null - minBufferSize = 0 - FFI.setFrameRawEnable("audio", false) - Log.d(logTag, "Exit audio thread") - } - } catch (e: Exception) { - Log.d(logTag, "startAudioRecorder fail:$e") + private fun releaseRecorder(recorder: AudioRecord) { + try { + recorder.release() + } finally { + if (audioRecorder === recorder) { + audioRecorder = null } - } else { - Log.d(logTag, "startAudioRecorder fail") } } + private fun captureAudio(reader: AudioReader, recorder: AudioRecord) { + try { + while (audioRecordStat) { + reader.readSync(recorder)?.let { + FFI.onAudioFrameUpdate(it) + } + } + } finally { + minBufferSize = 0 + try { + releaseRecorder(recorder) + } finally { + releaseAudioFramePublisher() + Log.d(logTag, "Exit audio thread") + } + } + } + + @RequiresApi(Build.VERSION_CODES.M) + fun startAudioRecorder(): Boolean { + val recorder = audioRecorder + if (recorder == null) { + Log.d(logTag, "startAudioRecorder fail") + return false + } + var audioFramePublisherAcquired = false + return try { + checkAudioReader() + val reader = audioReader + if (reader == null || minBufferSize == 0) { + releaseRecorder(recorder) + Log.d(logTag, "startAudioRecorder fail") + return false + } + recorder.startRecording() + if (recorder.recordingState != AudioRecord.RECORDSTATE_RECORDING) { + throw IllegalStateException("AudioRecord failed to enter recording state") + } + audioRecordStat = true + val captureThread = thread(start = false) { captureAudio(reader, recorder) } + acquireAudioFramePublisher() + audioFramePublisherAcquired = true + audioThread = captureThread + captureThread.start() + true + } catch (error: Exception) { + audioRecordStat = false + audioThread = null + Log.e(logTag, "startAudioRecorder fail", error) + try { + releaseRecorder(recorder) + } finally { + if (audioFramePublisherAcquired) { + releaseAudioFramePublisher() + } + } + false + } + } + + fun isVoiceCallActive(): Boolean { + return audioRecorder?.audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION + } + fun onVoiceCallStarted(mediaProjection: MediaProjection?): Boolean { if (!isSupportVoiceCall()) { return false @@ -137,11 +209,9 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart: if (!isSupportVoiceCall()) { return true } - if (isVideoStart()) { - switchOutVoiceCall(mediaProjection) - } + val switched = !isVideoStart() || switchOutVoiceCall(mediaProjection) tryReleaseAudio() - return true + return switched } @RequiresApi(Build.VERSION_CODES.M) @@ -159,8 +229,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart: Log.e(logTag, "createAudioRecorder fail") return false } - startAudioRecorder() - return true + return startAudioRecorder() } @RequiresApi(Build.VERSION_CODES.M) @@ -177,8 +246,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart: Log.e(logTag, "createAudioRecorder fail") return false } - startAudioRecorder() - return true + return startAudioRecorder() } fun tryReleaseAudio() { diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt index 4648b9adc..cfee6ab47 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt @@ -17,6 +17,7 @@ import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.content.pm.ServiceInfo import android.content.res.Configuration import android.content.res.Configuration.ORIENTATION_LANDSCAPE import android.graphics.Color @@ -150,7 +151,7 @@ class MainService : Service() { if (incomingVoiceCall) { voiceCallRequestNotification(id, "Voice Call Request", username, peerId) } else { - if (!audioRecordHandle.switchOutVoiceCall(mediaProjection)) { + if (!switchOutVoiceCall()) { Log.e(logTag, "switchOutVoiceCall fail") MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf( "type" to "custom-nook-nocancel-hasclose-error", @@ -159,7 +160,7 @@ class MainService : Service() { } } } else { - if (!audioRecordHandle.switchToVoiceCall(mediaProjection)) { + if (!switchToVoiceCall()) { Log.e(logTag, "switchToVoiceCall fail") MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf( "type" to "custom-nook-nocancel-hasclose-error", @@ -214,17 +215,19 @@ class MainService : Service() { // video private var mediaProjection: MediaProjection? = null - private val mediaProjectionCallback = object : MediaProjection.Callback() { - override fun onStop() { - Log.d(logTag, "MediaProjection stopped") - stopCapture() - virtualDisplay?.release() - virtualDisplay = null - releaseMediaProjection() - _isReady = false - checkMediaPermission() + private var mediaProjectionCallback: MediaProjection.Callback? = null + private var captureRestartPending = false + private var captureRestartInVoiceCall = false + private val mediaProjectionResultReceiver = + object : ResultReceiver(Handler(Looper.getMainLooper())) { + override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { + if (resultCode == RES_FAILED) { + cancelMediaProjectionRecovery() + } + } } - } + private var mediaProjectionForegroundService = false + private var microphoneForegroundService = false private var surface: Surface? = null private val sendVP9Thread = Executors.newSingleThreadExecutor() private var videoEncoder: MediaCodec? = null @@ -350,8 +353,6 @@ class MainService : Service() { Log.d("whichService", "this service: ${Thread.currentThread()}") super.onStartCommand(intent, flags, startId) if (intent?.action == ACT_INIT_MEDIA_PROJECTION_AND_SERVICE) { - createForegroundNotification() - if (intent.getBooleanExtra(EXT_INIT_FROM_BOOT, false)) { FFI.startService() } @@ -360,13 +361,7 @@ class MainService : Service() { getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager intent.getParcelableExtra(EXT_MEDIA_PROJECTION_RES_INTENT)?.let { - releaseMediaProjection() - val projection = - mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, it) - projection.registerCallback(mediaProjectionCallback, Handler(Looper.getMainLooper())) - mediaProjection = projection - _isReady = true - checkMediaPermission() + replaceMediaProjection(mediaProjectionManager, it) } ?: let { Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection") requestMediaProjection() @@ -380,18 +375,21 @@ class MainService : Service() { updateScreenInfo(newConfig.orientation) } - private fun requestMediaProjection() { + private fun requestMediaProjection(recovery: Boolean = false) { val intent = Intent(this, PermissionRequestTransparentActivity::class.java).apply { action = ACT_REQUEST_MEDIA_PROJECTION flags = Intent.FLAG_ACTIVITY_NEW_TASK + if (recovery) { + putExtra(EXT_MEDIA_PROJECTION_RESULT_RECEIVER, mediaProjectionResultReceiver) + } } startActivity(intent) } - private fun releaseMediaProjection() { - mediaProjection?.unregisterCallback(mediaProjectionCallback) - mediaProjection?.stop() - mediaProjection = null + @Synchronized + private fun cancelMediaProjectionRecovery() { + captureRestartPending = false + captureRestartInVoiceCall = false } @SuppressLint("WrongConstant") @@ -427,15 +425,149 @@ class MainService : Service() { } } - fun onVoiceCallStarted(): Boolean { - return audioRecordHandle.onVoiceCallStarted(mediaProjection) + private fun releaseMediaProjection() { + val projection = mediaProjection + val callback = mediaProjectionCallback + mediaProjection = null + mediaProjectionCallback = null + if (projection != null && callback != null) { + projection.unregisterCallback(callback) + } + projection?.stop() } + @Synchronized + private fun handleMediaProjectionStopped(stoppedProjection: MediaProjection) { + if (mediaProjection !== stoppedProjection) { + return + } + Log.d(logTag, "MediaProjection stopped") + setMediaProjectionForegroundService(false) + stopCapture() + virtualDisplay?.release() + virtualDisplay = null + mediaProjection = null + mediaProjectionCallback = null + _isReady = false + checkMediaPermission() + } + + @Synchronized + private fun replaceMediaProjection( + mediaProjectionManager: MediaProjectionManager, + resultIntent: Intent, + ) { + val wasCapturing = isStart + val restartCapture = wasCapturing || captureRestartPending + val restartInVoiceCall = if (wasCapturing) { + audioRecordHandle.isVoiceCallActive() + } else { + captureRestartInVoiceCall + } + val hadProjection = mediaProjection != null + if (!setMediaProjectionForegroundService(true)) { + if (!hadProjection) { + cancelMediaProjectionRecovery() + _isReady = false + checkMediaPermission() + } + return + } + val projection = + mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, resultIntent) + if (projection == null) { + if (!hadProjection) { + cancelMediaProjectionRecovery() + _isReady = false + setMediaProjectionForegroundService(false) + checkMediaPermission() + } + return + } + if (wasCapturing) { + stopCapture() + } + captureRestartPending = restartCapture + virtualDisplay?.release() + virtualDisplay = null + releaseMediaProjection() + val callback = object : MediaProjection.Callback() { + override fun onStop() { + handleMediaProjectionStopped(projection) + } + } + projection.registerCallback(callback, Handler(Looper.getMainLooper())) + mediaProjection = projection + mediaProjectionCallback = callback + _isReady = true + checkMediaPermission() + if (restartCapture) { + captureRestartPending = false + startCapture(restartInVoiceCall) + } + } + + @Synchronized + private fun startMicrophoneCapture(startAudio: () -> Boolean): Boolean { + if (!setMicrophoneForegroundService(true)) { + return false + } + if (startAudio()) { + return true + } + setMicrophoneForegroundService(false) + return false + } + + @Synchronized + private fun stopMicrophoneCapture(stopAudio: () -> Boolean): Boolean { + val stopped = stopAudio() + val foregroundServiceUpdated = setMicrophoneForegroundService(false) + return stopped && foregroundServiceUpdated + } + + @Synchronized + private fun switchToVoiceCall(): Boolean { + if (captureRestartPending) { + captureRestartInVoiceCall = true + } + return startMicrophoneCapture { + audioRecordHandle.switchToVoiceCall(mediaProjection) + } + } + + @Synchronized + private fun switchOutVoiceCall(): Boolean { + captureRestartInVoiceCall = false + val switched = audioRecordHandle.switchOutVoiceCall(mediaProjection) + val foregroundServiceUpdated = setMicrophoneForegroundService(false) + return switched && foregroundServiceUpdated + } + + @Synchronized + fun onVoiceCallStarted(): Boolean { + if (captureRestartPending) { + captureRestartInVoiceCall = true + } + return startMicrophoneCapture { + audioRecordHandle.onVoiceCallStarted(mediaProjection) + } + } + + @Synchronized fun onVoiceCallClosed(): Boolean { - return audioRecordHandle.onVoiceCallClosed(mediaProjection) + captureRestartInVoiceCall = false + return stopMicrophoneCapture { + audioRecordHandle.onVoiceCallClosed(mediaProjection) + } } fun startCapture(): Boolean { + return startCapture(false) + } + + @Synchronized + private fun startCapture(inVoiceCall: Boolean): Boolean { if (isStart) { return true } @@ -443,25 +575,35 @@ class MainService : Service() { Log.w(logTag, "startCapture fail,mediaProjection is null") return false } + captureRestartInVoiceCall = inVoiceCall updateScreenInfo(resources.configuration.orientation) Log.d(logTag, "Start Capture") surface = createSurface() - if (useVP9) { + val videoStarted = if (useVP9) { startVP9VideoRecorder(mediaProjection!!) } else { startRawVideoRecorder(mediaProjection!!) } + if (!videoStarted) { + if (!captureRestartPending) { + captureRestartInVoiceCall = false + } + releaseFailedVideoCapture() + return false + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - if (!audioRecordHandle.createAudioRecorder(false, mediaProjection)) { - Log.d(logTag, "createAudioRecorder fail") + val audioStarted = if (inVoiceCall) { + switchToVoiceCall() } else { - Log.d(logTag, "audio recorder start") - audioRecordHandle.startAudioRecorder() + audioRecordHandle.createAudioRecorder(false, mediaProjection) && + audioRecordHandle.startAudioRecorder() } + Log.d(logTag, if (audioStarted) "audio recorder start" else "audio recorder start failed") } + captureRestartInVoiceCall = false checkMediaPermission() _isStart = true FFI.setFrameRawEnable("video",true) @@ -469,9 +611,24 @@ class MainService : Service() { return true } + private fun releaseFailedVideoCapture() { + imageReader?.close() + imageReader = null + videoEncoder?.let { + it.signalEndOfInputStream() + it.stop() + it.release() + } + videoEncoder = null + surface?.release() + surface = null + } + @Synchronized fun stopCapture() { Log.d(logTag, "Stop Capture") + captureRestartPending = false + captureRestartInVoiceCall = false FFI.setFrameRawEnable("video",false) _isStart = false MainActivity.rdClipboardManager?.setCaptureStarted(_isStart) @@ -502,8 +659,11 @@ class MainService : Service() { surface?.release() // release audio - _isAudioStart = false - audioRecordHandle.tryReleaseAudio() + stopMicrophoneCapture { + _isAudioStart = false + audioRecordHandle.tryReleaseAudio() + true + } } fun destroy() { @@ -519,6 +679,8 @@ class MainService : Service() { } releaseMediaProjection() + mediaProjectionForegroundService = false + microphoneForegroundService = false checkMediaPermission() stopForeground(true) stopService(Intent(this, FloatingWindowService::class.java)) @@ -541,49 +703,70 @@ class MainService : Service() { return isReady } - private fun startRawVideoRecorder(mp: MediaProjection) { + private fun startRawVideoRecorder(mp: MediaProjection): Boolean { Log.d(logTag, "startRawVideoRecorder,screen info:$SCREEN_INFO") - if (surface == null) { + val captureSurface = surface + if (captureSurface == null) { Log.d(logTag, "startRawVideoRecorder failed,surface is null") - return + return false } - createOrSetVirtualDisplay(mp, surface!!) + return createOrSetVirtualDisplay(mp, captureSurface) } - private fun startVP9VideoRecorder(mp: MediaProjection) { + private fun startVP9VideoRecorder(mp: MediaProjection): Boolean { createMediaCodec() - videoEncoder?.let { - surface = it.createInputSurface() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - surface!!.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT) - } - it.setCallback(cb) - it.start() - createOrSetVirtualDisplay(mp, surface!!) + val encoder = videoEncoder ?: return false + val inputSurface = encoder.createInputSurface() + surface = inputSurface + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + inputSurface.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT) } + encoder.setCallback(cb) + encoder.start() + return createOrSetVirtualDisplay(mp, inputSurface) } // https://github.com/bk138/droidVNC-NG/blob/b79af62db5a1c08ed94e6a91464859ffed6f4e97/app/src/main/java/net/christianbeier/droidvnc_ng/MediaProjectionService.java#L250 // Reuse virtualDisplay if it exists, to avoid media projection confirmation dialog every connection. - private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface) { - try { - virtualDisplay?.let { - it.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi) - it.setSurface(s) - } ?: let { - virtualDisplay = mp.createVirtualDisplay( + private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface): Boolean { + return try { + val existingDisplay = virtualDisplay + if (existingDisplay != null) { + existingDisplay.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi) + existingDisplay.setSurface(s) + true + } else { + val display = mp.createVirtualDisplay( "RustDeskVD", SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi, VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, s, null, null ) + if (display == null) { + Log.e(logTag, "createOrSetVirtualDisplay failed") + handleVirtualDisplayFailure() + } else { + virtualDisplay = display + true + } } } catch (e: SecurityException) { - Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException, re-requesting confirmation"); - // This initiates a prompt dialog for the user to confirm screen projection. - requestMediaProjection() + Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException", e) + handleVirtualDisplayFailure() } } + private fun handleVirtualDisplayFailure(): Boolean { + captureRestartPending = true + virtualDisplay?.release() + virtualDisplay = null + releaseMediaProjection() + setMediaProjectionForegroundService(false) + _isReady = false + checkMediaPermission() + requestMediaProjection(true) + return false + } + private val cb: MediaCodec.Callback = object : MediaCodec.Callback() { override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {} override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {} @@ -674,7 +857,63 @@ class MainService : Service() { .setColor(ContextCompat.getColor(this, R.color.primary)) .setWhen(System.currentTimeMillis()) .build() - startForeground(DEFAULT_NOTIFY_ID, notification) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(DEFAULT_NOTIFY_ID, notification, foregroundServiceType()) + } else { + startForeground(DEFAULT_NOTIFY_ID, notification) + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun foregroundServiceType(): Int { + var serviceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // Keep a valid FGS type while the unattended host is idle and no capture type is active. + serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + } + if (mediaProjectionForegroundService) { + serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && microphoneForegroundService) { + serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } + return serviceType + } + + private fun setMediaProjectionForegroundService(enabled: Boolean): Boolean { + return updateForegroundServiceTypes(enabled, microphoneForegroundService) + } + + private fun setMicrophoneForegroundService(enabled: Boolean): Boolean { + return updateForegroundServiceTypes(mediaProjectionForegroundService, enabled) + } + + private fun updateForegroundServiceTypes( + mediaProjectionEnabled: Boolean, + microphoneEnabled: Boolean, + ): Boolean { + if (mediaProjectionForegroundService == mediaProjectionEnabled && + microphoneForegroundService == microphoneEnabled) { + return true + } + val previousMediaProjection = mediaProjectionForegroundService + val previousMicrophone = microphoneForegroundService + mediaProjectionForegroundService = mediaProjectionEnabled + microphoneForegroundService = microphoneEnabled + return try { + createForegroundNotification() + true + } catch (error: SecurityException) { + mediaProjectionForegroundService = previousMediaProjection + microphoneForegroundService = previousMicrophone + Log.e(logTag, "Failed to update foreground service types", error) + false + } catch (error: IllegalStateException) { + mediaProjectionForegroundService = previousMediaProjection + microphoneForegroundService = previousMicrophone + Log.e(logTag, "Failed to update foreground service types", error) + false + } } private fun loginRequestNotification( diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/PermissionRequestTransparentActivity.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/PermissionRequestTransparentActivity.kt index 3beb7ec6b..9034d2096 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/PermissionRequestTransparentActivity.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/PermissionRequestTransparentActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.media.projection.MediaProjectionManager import android.os.Build import android.os.Bundle +import android.os.ResultReceiver import android.util.Log class PermissionRequestTransparentActivity: Activity() { @@ -31,7 +32,13 @@ class PermissionRequestTransparentActivity: Activity() { if (resultCode == RESULT_OK && data != null) { launchService(data) } else { - setResult(RES_FAILED) + val resultReceiver = + intent.getParcelableExtra(EXT_MEDIA_PROJECTION_RESULT_RECEIVER) + if (resultReceiver != null) { + resultReceiver.send(RES_FAILED, null) + } else { + setResult(RES_FAILED) + } } } @@ -51,4 +58,4 @@ class PermissionRequestTransparentActivity: Activity() { } } -} \ No newline at end of file +} diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt index 2923cad9f..b59dca945 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt @@ -33,6 +33,7 @@ const val ACT_INIT_MEDIA_PROJECTION_AND_SERVICE = "INIT_MEDIA_PROJECTION_AND_SER const val ACT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY" const val EXT_INIT_FROM_BOOT = "EXT_INIT_FROM_BOOT" const val EXT_MEDIA_PROJECTION_RES_INTENT = "MEDIA_PROJECTION_RES_INTENT" +const val EXT_MEDIA_PROJECTION_RESULT_RECEIVER = "MEDIA_PROJECTION_RESULT_RECEIVER" const val EXT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY" // Activity requestCode @@ -164,4 +165,4 @@ fun getScreenSize(windowManager: WindowManager) : Pair{ fun translate(input: String): String { Log.d("common", "translate:$LOCAL_NAME") return FFI.translateLocale(LOCAL_NAME, input) -} \ No newline at end of file +} diff --git a/flutter/android/app/src/main/res/values/strings.xml b/flutter/android/app/src/main/res/values/strings.xml index 3e058a81b..eae55d590 100644 --- a/flutter/android/app/src/main/res/values/strings.xml +++ b/flutter/android/app/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ RustDesk Allow other devices to control your phone using virtual touch, when RustDesk screen sharing is established + Keeps the RustDesk remote desktop host available for authorized unattended connections and foreground notifications without starting screen capture before user approval. diff --git a/flutter/android/build.gradle b/flutter/android/build.gradle index 401bea009..5733740c2 100644 --- a/flutter/android/build.gradle +++ b/flutter/android/build.gradle @@ -1,3 +1,29 @@ +def legacyPluginNamespaces = [ + external_path: 'com.pinciat.external_path', + flutter_keyboard_visibility: 'com.jrai.flutter_keyboard_visibility', + qr_code_scanner: 'net.touchcapture.qr.flutterqr', + sqflite: 'com.tekartik.sqflite', + uni_links: 'name.avioli.unilinks', +] + +def java8JvmTarget = JavaVersion.VERSION_1_8.toString() +def java8KotlinJvmTargets = [ + app: java8JvmTarget, + external_path: java8JvmTarget, + qr_code_scanner: java8JvmTarget, +] + +def configureKotlinJvmTarget = { Project project, String kotlinJvmTarget -> + project.plugins.withId('kotlin-android') { + project.tasks.configureEach { task -> + if (!task.hasProperty('kotlinOptions')) { + return + } + task.kotlinOptions.jvmTarget = kotlinJvmTarget + } + } +} + allprojects { repositories { google() @@ -9,6 +35,16 @@ allprojects { rootProject.buildDir = '../build' subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" + def legacyNamespace = legacyPluginNamespaces[project.name] + if (legacyNamespace != null) { + project.plugins.withId('com.android.library') { + project.android.namespace = legacyNamespace + } + } + def kotlinJvmTarget = java8KotlinJvmTargets[project.name] + if (kotlinJvmTarget != null) { + configureKotlinJvmTarget(project, kotlinJvmTarget) + } } subprojects { project.evaluationDependsOn(':app') diff --git a/flutter/android/gradle/wrapper/gradle-wrapper.properties b/flutter/android/gradle/wrapper/gradle-wrapper.properties index cb576305f..9162f1008 100644 --- a/flutter/android/gradle/wrapper/gradle-wrapper.properties +++ b/flutter/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip diff --git a/flutter/android/settings.gradle b/flutter/android/settings.gradle index ae32fa00e..b72bea584 100644 --- a/flutter/android/settings.gradle +++ b/flutter/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.3.1" apply false + id "com.android.application" version "8.10.1" apply false id "org.jetbrains.kotlin.android" version "2.1.21" apply false } From 28cf1836e62442e9bccfa3033c9d5e6cf67de1e8 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Tue, 1 Sep 2026 08:57:33 +0800 Subject: [PATCH 71/72] bump to 1.5.0 --- .github/workflows/flutter-build.yml | 2 +- .github/workflows/playground.yml | 2 +- Cargo.lock | 4 ++-- Cargo.toml | 2 +- appimage/AppImageBuilder-aarch64.yml | 2 +- appimage/AppImageBuilder-x86_64.yml | 2 +- flutter/pubspec.yaml | 2 +- libs/portable/Cargo.toml | 2 +- res/PKGBUILD | 2 +- res/rpm-flutter-suse.spec | 2 +- res/rpm-flutter.spec | 2 +- res/rpm.spec | 2 +- 12 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 1b0a6424a..409b5201d 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -44,7 +44,7 @@ env: # 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`. VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d" ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version - VERSION: "1.4.9" + VERSION: "1.5.0" NDK_VERSION: "r28c" #signing keys env variable checks ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml index 7dee9b83a..7478bee9d 100644 --- a/.github/workflows/playground.yml +++ b/.github/workflows/playground.yml @@ -17,7 +17,7 @@ env: TAG_NAME: "nightly" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d" - VERSION: "1.4.9" + VERSION: "1.5.0" NDK_VERSION: "r26d" #signing keys env variable checks ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" diff --git a/Cargo.lock b/Cargo.lock index 7448d84c3..1746adc00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7177,7 +7177,7 @@ dependencies = [ [[package]] name = "rustdesk" -version = "1.4.9" +version = "1.5.0" dependencies = [ "android-wakelock", "android_logger", @@ -7287,7 +7287,7 @@ dependencies = [ [[package]] name = "rustdesk-portable-packer" -version = "1.4.9" +version = "1.5.0" dependencies = [ "brotli", "dirs 5.0.1", diff --git a/Cargo.toml b/Cargo.toml index 7d08cb3f1..b1d9b7f91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustdesk" -version = "1.4.9" +version = "1.5.0" authors = ["rustdesk "] edition = "2021" build= "build.rs" diff --git a/appimage/AppImageBuilder-aarch64.yml b/appimage/AppImageBuilder-aarch64.yml index e7284f4f0..2a0061bef 100644 --- a/appimage/AppImageBuilder-aarch64.yml +++ b/appimage/AppImageBuilder-aarch64.yml @@ -18,7 +18,7 @@ AppDir: id: rustdesk name: rustdesk icon: rustdesk - version: 1.4.9 + version: 1.5.0 exec: usr/share/rustdesk/rustdesk exec_args: $@ apt: diff --git a/appimage/AppImageBuilder-x86_64.yml b/appimage/AppImageBuilder-x86_64.yml index 5ec386c7d..49ede99eb 100644 --- a/appimage/AppImageBuilder-x86_64.yml +++ b/appimage/AppImageBuilder-x86_64.yml @@ -18,7 +18,7 @@ AppDir: id: rustdesk name: rustdesk icon: rustdesk - version: 1.4.9 + version: 1.5.0 exec: usr/share/rustdesk/rustdesk exec_args: $@ apt: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index d67ce0003..1868a426e 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers -version: 1.4.9+67 +version: 1.5.0+68 environment: sdk: '^3.1.0' diff --git a/libs/portable/Cargo.toml b/libs/portable/Cargo.toml index aacfdcf9b..bcf08f386 100644 --- a/libs/portable/Cargo.toml +++ b/libs/portable/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustdesk-portable-packer" -version = "1.4.9" +version = "1.5.0" edition = "2021" description = "RustDesk Remote Desktop" diff --git a/res/PKGBUILD b/res/PKGBUILD index b3b64f311..f66d62511 100644 --- a/res/PKGBUILD +++ b/res/PKGBUILD @@ -1,5 +1,5 @@ pkgname=rustdesk -pkgver=1.4.9 +pkgver=1.5.0 pkgrel=0 epoch= pkgdesc="" diff --git a/res/rpm-flutter-suse.spec b/res/rpm-flutter-suse.spec index 5b8a0d416..234c7beed 100644 --- a/res/rpm-flutter-suse.spec +++ b/res/rpm-flutter-suse.spec @@ -1,5 +1,5 @@ Name: rustdesk -Version: 1.4.9 +Version: 1.5.0 Release: 0 Summary: RPM package License: GPL-3.0 diff --git a/res/rpm-flutter.spec b/res/rpm-flutter.spec index 70fef6325..95007c251 100644 --- a/res/rpm-flutter.spec +++ b/res/rpm-flutter.spec @@ -1,5 +1,5 @@ Name: rustdesk -Version: 1.4.9 +Version: 1.5.0 Release: 0 Summary: RPM package License: GPL-3.0 diff --git a/res/rpm.spec b/res/rpm.spec index 18eb46c75..ef30dfba3 100644 --- a/res/rpm.spec +++ b/res/rpm.spec @@ -1,5 +1,5 @@ Name: rustdesk -Version: 1.4.9 +Version: 1.5.0 Release: 0 Summary: RPM package License: GPL-3.0 From f28ac38ccfa662fd06639a062e0d06249860b142 Mon Sep 17 00:00:00 2001 From: palmoni5 Date: Tue, 1 Sep 2026 02:47:26 +0000 Subject: [PATCH 72/72] feat: optionally sync clipboard between connected sessions (#15934) * feat(clipboard): optionally sync clipboard between connected sessions Clipboard content received from a remote session is written to the local clipboard with an owner marker, so the client clipboard loop deliberately skips re-broadcasting it to avoid echo loops. As a result, text copied in one remote window could not be pasted in another connected remote window. Add an opt-in local option (allow-sync-clipboard-between-sessions) that relays Clipboard/MultiClipboards messages received from one session to all other connected sessions, excluding the source session. Per-session clipboard permissions and view-only mode are still respected via the existing send path, and the owner marker on the receiving peers prevents any echo back. Desktop (flutter) only; file clipboard is not affected. * fix(lang): propagate sync-clipboard-between-sessions-tip to all locale files Add the new key to template.rs and every locale file per the localization convention, move the en.rs entry to the end of the list, and drop comments that only restated the names next to them. * fix(lang): add the 'Sync clipboard between sessions' label to the localization catalog The checkbox label goes through translate(), so add it to template.rs and every locale file so non-English locales can translate it. en.rs is skipped since the English display text is identical to the key. * fix(clipboard): check the source session's full clipboard permission before relaying The relay was gated only by the incoming clipboard_allowed check (!disable_clipboard && !view_only). Gate it with is_text_clipboard_required() instead, which additionally respects the source session's server_clipboard_enabled and server_keyboard_enabled state, matching the predicate already applied to destination sessions. A message arriving after the source permission was revoked (or from a non-conforming peer) is no longer propagated to other sessions. The existing local update_clipboard behavior is unchanged. * fix(lang): translate the new clipboard sync entries in all locale files Fill the 'Sync clipboard between sessions' label and its tooltip in every locale file instead of leaving them blank, following each file's existing terminology. template.rs keeps the empty master entries. --- flutter/lib/consts.dart | 2 ++ .../desktop/pages/desktop_setting_page.dart | 9 +++++++ src/client/io_loop.rs | 24 +++++++++++++++++++ src/clipboard.rs | 11 +++++++++ src/flutter.rs | 16 +++++++++++++ src/lang/ar.rs | 2 ++ src/lang/be.rs | 2 ++ src/lang/bg.rs | 2 ++ src/lang/ca.rs | 2 ++ src/lang/cn.rs | 2 ++ src/lang/cs.rs | 2 ++ src/lang/da.rs | 2 ++ src/lang/de.rs | 2 ++ src/lang/el.rs | 2 ++ src/lang/en.rs | 1 + src/lang/eo.rs | 2 ++ src/lang/es.rs | 2 ++ src/lang/et.rs | 2 ++ src/lang/eu.rs | 2 ++ src/lang/fa.rs | 2 ++ src/lang/fi.rs | 2 ++ src/lang/fr.rs | 2 ++ src/lang/ge.rs | 2 ++ src/lang/gu.rs | 2 ++ src/lang/he.rs | 2 ++ src/lang/hi.rs | 2 ++ src/lang/hr.rs | 2 ++ src/lang/hu.rs | 2 ++ src/lang/id.rs | 2 ++ src/lang/it.rs | 2 ++ src/lang/ja.rs | 2 ++ src/lang/ko.rs | 2 ++ src/lang/kz.rs | 2 ++ src/lang/lt.rs | 2 ++ src/lang/lv.rs | 2 ++ src/lang/ml.rs | 2 ++ src/lang/nb.rs | 2 ++ src/lang/nl.rs | 2 ++ src/lang/pl.rs | 2 ++ src/lang/pt_PT.rs | 2 ++ src/lang/ptbr.rs | 2 ++ src/lang/ro.rs | 2 ++ src/lang/ru.rs | 2 ++ src/lang/sc.rs | 2 ++ src/lang/sk.rs | 2 ++ src/lang/sl.rs | 2 ++ src/lang/sq.rs | 2 ++ src/lang/sr.rs | 2 ++ src/lang/sv.rs | 2 ++ src/lang/ta.rs | 2 ++ src/lang/template.rs | 2 ++ src/lang/th.rs | 2 ++ src/lang/tr.rs | 2 ++ src/lang/tw.rs | 2 ++ src/lang/uk.rs | 2 ++ src/lang/vi.rs | 2 ++ 56 files changed, 163 insertions(+) diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index ca0bd523f..c6f9d9d6b 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -168,6 +168,8 @@ const String kOptionDirectxCapture = "enable-directx-capture"; const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification"; const String kOptionEnableUdpPunch = "enable-udp-punch"; const String kOptionEnableIpv6Punch = "enable-ipv6-punch"; +const String kOptionAllowSyncClipboardBetweenSessions = + "allow-sync-clipboard-between-sessions"; const String kOptionEnableTrustedDevices = "enable-trusted-devices"; const String kOptionShowVirtualMouse = "show-virtual-mouse"; const String kOptionVirtualMouseScale = "virtual-mouse-scale"; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 4f3ea42e2..256e3923f 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -575,6 +575,15 @@ class _GeneralState extends State<_General> { kOptionEnableIpv6Punch, isServer: false, ), + Tooltip( + message: translate('sync-clipboard-between-sessions-tip'), + child: _OptionCheckBox( + context, + 'Sync clipboard between sessions', + kOptionAllowSyncClipboardBetweenSessions, + isServer: false, + ), + ), ], ]; diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index 33ee93357..d7a4f570f 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -1462,6 +1462,18 @@ impl Remote { !lc.disable_clipboard.v && !lc.view_only.v }; if clipboard_allowed { + #[cfg(all( + feature = "flutter", + not(any(target_os = "android", target_os = "ios")) + ))] + if self.handler.is_text_clipboard_required() + && crate::clipboard::is_sync_clipboard_between_sessions_enabled() + { + let mut msg = Message::new(); + msg.set_clipboard(cb.clone()); + let session_id = self.handler.lc.read().unwrap().session_id; + crate::flutter::send_clipboard_msg_to_other_sessions(msg, session_id); + } #[cfg(not(any(target_os = "android", target_os = "ios")))] update_clipboard(vec![cb], ClipboardSide::Client); #[cfg(target_os = "ios")] @@ -1485,6 +1497,18 @@ impl Remote { !lc.disable_clipboard.v && !lc.view_only.v }; if clipboard_allowed { + #[cfg(all( + feature = "flutter", + not(any(target_os = "android", target_os = "ios")) + ))] + if self.handler.is_text_clipboard_required() + && crate::clipboard::is_sync_clipboard_between_sessions_enabled() + { + let mut msg = Message::new(); + msg.set_multi_clipboards(_mcb.clone()); + let session_id = self.handler.lc.read().unwrap().session_id; + crate::flutter::send_clipboard_msg_to_other_sessions(msg, session_id); + } #[cfg(not(any(target_os = "android", target_os = "ios")))] update_clipboard(_mcb.clipboards, ClipboardSide::Client); #[cfg(target_os = "ios")] diff --git a/src/clipboard.rs b/src/clipboard.rs index c7c01d6c4..2b6c8ba83 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -13,6 +13,17 @@ pub const CLIPBOARD_NAME: &'static str = "clipboard"; pub const FILE_CLIPBOARD_NAME: &'static str = "file-clipboard"; pub const CLIPBOARD_INTERVAL: u64 = 333; +pub const OPTION_ALLOW_SYNC_CLIPBOARD_BETWEEN_SESSIONS: &str = + "allow-sync-clipboard-between-sessions"; + +#[cfg(all(feature = "flutter", not(any(target_os = "android", target_os = "ios"))))] +pub fn is_sync_clipboard_between_sessions_enabled() -> bool { + hbb_common::config::option2bool( + OPTION_ALLOW_SYNC_CLIPBOARD_BETWEEN_SESSIONS, + &hbb_common::config::LocalConfig::get_option(OPTION_ALLOW_SYNC_CLIPBOARD_BETWEEN_SESSIONS), + ) +} + // This format is used to store the flag in the clipboard. const RUSTDESK_CLIPBOARD_OWNER_FORMAT: &'static str = "dyn.com.rustdesk.owner"; diff --git a/src/flutter.rs b/src/flutter.rs index 1ed44ee49..f4971f18c 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -1422,10 +1422,26 @@ pub fn update_file_clipboard_required() { #[cfg(not(target_os = "ios"))] pub fn send_clipboard_msg(msg: Message, _is_file: bool) { + send_clipboard_msg_impl(msg, _is_file, None); +} + +// `except_session_id` is the session the content came from, to avoid sending it back. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn send_clipboard_msg_to_other_sessions(msg: Message, except_session_id: u64) { + send_clipboard_msg_impl(msg, false, Some(except_session_id)); +} + +#[cfg(not(target_os = "ios"))] +fn send_clipboard_msg_impl(msg: Message, _is_file: bool, except_session_id: Option) { for s in sessions::get_sessions() { if !s.is_default() { continue; } + if let Some(except_session_id) = except_session_id { + if s.lc.read().unwrap().session_id == except_session_id { + continue; + } + } #[cfg(feature = "unix-file-copy-paste")] if _is_file { if crate::is_support_file_copy_paste_num(s.lc.read().unwrap().version) diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 33c80e6eb..da12b2017 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "متابعة"), ("Browser didn't open? Use the url below to sign in.", "لم يفتح المتصفح؟ استخدم الرابط أدناه لتسجيل الدخول."), ("Lock canvas", "قفل اللوحة"), + ("Sync clipboard between sessions", "مزامنة الحافظة بين الجلسات"), + ("sync-clipboard-between-sessions-tip", "النص أو الصور المنسوخة في جلسة بعيدة واحدة تُرسَل أيضًا إلى حافظة جلساتك المتصلة الأخرى."), ].iter().cloned().collect(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index 7a1635c27..2c012fb64 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Працягнуць"), ("Browser didn't open? Use the url below to sign in.", "Браўзер не адкрыўся? Скарыстайцеся спасылкай ніжэй, каб увайсці."), ("Lock canvas", "Заблакіраваць палатно"), + ("Sync clipboard between sessions", "Сінхранізаваць буфер абмену паміж сеансамі"), + ("sync-clipboard-between-sessions-tip", "Тэкст або відарысы, скапіяваныя ў адным аддаленым сеансе, таксама адпраўляюцца ў буфер абмену іншых вашых падключаных сеансаў."), ].iter().cloned().collect(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index d9f7cf842..4104846ee 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Продължи"), ("Browser didn't open? Use the url below to sign in.", "Браузърът не се отвори? Използвайте URL адреса по-долу, за да се впишете."), ("Lock canvas", "Заключване на платното"), + ("Sync clipboard between sessions", "Синхронизиране на клипборда между сесиите"), + ("sync-clipboard-between-sessions-tip", "Текст или изображения, копирани в една отдалечена сесия, се изпращат и към клипборда на другите ви свързани сесии."), ].iter().cloned().collect(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 196574688..8d73f523b 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Continua"), ("Browser didn't open? Use the url below to sign in.", "No s'ha obert el navegador? Utilitzeu l'URL de sota per iniciar la sessió."), ("Lock canvas", "Bloca el llenç"), + ("Sync clipboard between sessions", "Sincronitza el porta-retalls entre sessions"), + ("sync-clipboard-between-sessions-tip", "El text o les imatges copiats en una sessió remota també s'envien al porta-retalls de les altres sessions connectades."), ].iter().cloned().collect(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index be998606b..8a819fd7e 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "继续"), ("Browser didn't open? Use the url below to sign in.", "浏览器未打开?请使用下方网址登录。"), ("Lock canvas", "锁定画布"), + ("Sync clipboard between sessions", "在会话间同步剪贴板"), + ("sync-clipboard-between-sessions-tip", "在一个远程会话中复制的文本或图片也会发送到其他已连接会话的剪贴板。"), ].iter().cloned().collect(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index 21daea69c..71b151e41 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Pokračovat"), ("Browser didn't open? Use the url below to sign in.", "Neotevřel se prohlížeč? Pro přihlášení použijte URL níže."), ("Lock canvas", "Zamknout zobrazení"), + ("Sync clipboard between sessions", "Synchronizovat schránku mezi relacemi"), + ("sync-clipboard-between-sessions-tip", "Text nebo obrázky zkopírované v jedné vzdálené relaci se odešlou i do schránky ostatních připojených relací."), ].iter().cloned().collect(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index b29e3eddf..3ba116072 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Fortsæt"), ("Browser didn't open? Use the url below to sign in.", "Åbnede browseren ikke? Brug URL'en nedenfor til at logge ind."), ("Lock canvas", "Lås lærred"), + ("Sync clipboard between sessions", "Synkroniser udklipsholder mellem sessioner"), + ("sync-clipboard-between-sessions-tip", "Tekst eller billeder, der kopieres i én fjernsession, sendes også til udklipsholderen i dine andre forbundne sessioner."), ].iter().cloned().collect(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index 9c6e75bc6..54a691b7c 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Weiter"), ("Browser didn't open? Use the url below to sign in.", "Hat sich der Browser nicht geöffnet? Melden Sie sich über die untenstehende URL an."), ("Lock canvas", "Sichtfeld sperren"), + ("Sync clipboard between sessions", "Zwischenablage zwischen Sitzungen synchronisieren"), + ("sync-clipboard-between-sessions-tip", "In einer Remote-Sitzung kopierter Text oder kopierte Bilder werden auch an die Zwischenablage Ihrer anderen verbundenen Sitzungen gesendet."), ].iter().cloned().collect(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index d3d1e378f..e7c7e1d09 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Συνέχεια"), ("Browser didn't open? Use the url below to sign in.", "Δεν άνοιξε το πρόγραμμα περιήγησης; Χρησιμοποιήστε τον παρακάτω σύνδεσμο για να συνδεθείτε."), ("Lock canvas", "Κλείδωμα καμβά"), + ("Sync clipboard between sessions", "Συγχρονισμός προχείρου μεταξύ συνεδριών"), + ("sync-clipboard-between-sessions-tip", "Κείμενο ή εικόνες που αντιγράφονται σε μία απομακρυσμένη συνεδρία αποστέλλονται και στο πρόχειρο των άλλων συνδεδεμένων συνεδριών σας."), ].iter().cloned().collect(); } diff --git a/src/lang/en.rs b/src/lang/en.rs index 227a7e29e..3ffc0939c 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -275,5 +275,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("id_whitelist_caveat_tip", "The ID is reported by the connecting client. This whitelist reduces exposure and does not replace the password or 2FA."), ("whitelist_cidr_tip", "CIDR notation is supported, e.g. 192.168.1.0/24"), ("Your ip is blocked by the peer", "Your IP is blocked by the peer"), + ("sync-clipboard-between-sessions-tip", "Text or images copied in one remote session are also sent to the clipboard of your other connected sessions."), ].iter().cloned().collect(); } diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 4f9b0ccd7..e1edc9053 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Daŭrigi"), ("Browser didn't open? Use the url below to sign in.", "Ĉu la retumilo ne malfermiĝis? Uzu la suban ligilon por ensaluti."), ("Lock canvas", "Ŝlosi kanvason"), + ("Sync clipboard between sessions", "Sinkronigi poŝon inter seancoj"), + ("sync-clipboard-between-sessions-tip", "Teksto aŭ bildoj kopiitaj en unu fora seanco ankaŭ sendiĝas al la poŝo de viaj aliaj konektitaj seancoj."), ].iter().cloned().collect(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index 89926b43a..1967d56ab 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Continuar"), ("Browser didn't open? Use the url below to sign in.", "¿No se abrió el navegador? Usa la URL de abajo para iniciar sesión."), ("Lock canvas", "Bloquear lienzo"), + ("Sync clipboard between sessions", "Sincronizar portapapeles entre sesiones"), + ("sync-clipboard-between-sessions-tip", "El texto o las imágenes copiados en una sesión remota también se envían al portapapeles de tus otras sesiones conectadas."), ].iter().cloned().collect(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index 9bbb7b07d..6b8b715f8 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Jätka"), ("Browser didn't open? Use the url below to sign in.", "Brauser ei avanenud? Sisselogimiseks kasuta allolevat URL-i."), ("Lock canvas", "Lukusta lõuend"), + ("Sync clipboard between sessions", "Sünkrooni lõikelaud seansside vahel"), + ("sync-clipboard-between-sessions-tip", "Ühes kaugseansis kopeeritud tekst või pildid saadetakse ka teiste ühendatud seansside lõikelauale."), ].iter().cloned().collect(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index 8515e6f53..06783379a 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Jarraitu"), ("Browser didn't open? Use the url below to sign in.", "Nabigatzailea ez da ireki? Erabili beheko URLa saioa hasteko."), ("Lock canvas", "Blokeatu oihala"), + ("Sync clipboard between sessions", "Sinkronizatu arbela saioen artean"), + ("sync-clipboard-between-sessions-tip", "Urruneko saio batean kopiatutako testua edo irudiak konektatutako beste saioen arbelera ere bidaltzen dira."), ].iter().cloned().collect(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index a96fe7160..dd16ed09a 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "ادامه"), ("Browser didn't open? Use the url below to sign in.", "مرورگر باز نشد؟ برای ورود از نشانی زیر استفاده کنید."), ("Lock canvas", "قفل کردن صفحه"), + ("Sync clipboard between sessions", "همگام‌سازی کلیپ‌بورد بین نشست‌ها"), + ("sync-clipboard-between-sessions-tip", "متن یا تصاویری که در یک نشست راه دور کپی می‌شوند به کلیپ‌بورد سایر نشست‌های متصل شما نیز ارسال می‌شوند."), ].iter().cloned().collect(); } diff --git a/src/lang/fi.rs b/src/lang/fi.rs index d5695d18d..3b00e00b1 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Jatka"), ("Browser didn't open? Use the url below to sign in.", "Eikö selain avautunut? Kirjaudu sisään alla olevan osoitteen kautta."), ("Lock canvas", "Lukitse näkymä"), + ("Sync clipboard between sessions", "Synkronoi leikepöytä istuntojen välillä"), + ("sync-clipboard-between-sessions-tip", "Yhdessä etäistunnossa kopioitu teksti tai kuvat lähetetään myös muiden yhdistettyjen istuntojen leikepöydälle."), ].iter().cloned().collect(); } diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 4dece7adc..11372cc51 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Continuer"), ("Browser didn't open? Use the url below to sign in.", "Le navigateur ne s’est pas ouvert ? Utilisez l’URL ci-dessous pour vous connecter."), ("Lock canvas", "Verrouiller la vue"), + ("Sync clipboard between sessions", "Synchroniser le presse-papiers entre les sessions"), + ("sync-clipboard-between-sessions-tip", "Le texte ou les images copiés dans une session distante sont également envoyés au presse-papiers de vos autres sessions connectées."), ].iter().cloned().collect(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs index a422c4853..edacbb4f5 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "გაგრძელება"), ("Browser didn't open? Use the url below to sign in.", "ბრაუზერი არ გაიხსნა? შესასვლელად გამოიყენეთ ქვემოთ მოცემული ბმული."), ("Lock canvas", "ტილოს დაბლოკვა"), + ("Sync clipboard between sessions", "გაცვლის ბუფერის სინქრონიზაცია სესიებს შორის"), + ("sync-clipboard-between-sessions-tip", "ერთ დაშორებულ სესიაში დაკოპირებული ტექსტი ან სურათები ასევე იგზავნება თქვენი სხვა დაკავშირებული სესიების გაცვლის ბუფერში."), ].iter().cloned().collect(); } diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 3a1c14139..c0d722940 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "ચાલુ રાખો"), ("Browser didn't open? Use the url below to sign in.", "બ્રાઉઝર ખૂલ્યું નથી? લોગિન કરવા માટે નીચે આપેલ URL નો ઉપયોગ કરો."), ("Lock canvas", "કેનવાસ લોક કરો"), + ("Sync clipboard between sessions", "સત્રો વચ્ચે ક્લિપબોર્ડ સિંક કરો"), + ("sync-clipboard-between-sessions-tip", "એક રિમોટ સત્રમાં કૉપિ કરેલ ટેક્સ્ટ કે છબીઓ તમારા અન્ય જોડાયેલા સત્રોના ક્લિપબોર્ડ પર પણ મોકલવામાં આવે છે."), ].iter().cloned().collect(); } diff --git a/src/lang/he.rs b/src/lang/he.rs index 2ff95b48f..8dd783f30 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "המשך"), ("Browser didn't open? Use the url below to sign in.", "הדפדפן לא נפתח? השתמש בכתובת שלמטה כדי להתחבר."), ("Lock canvas", "נעל לוח ציור"), + ("Sync clipboard between sessions", "סנכרן לוח בין סשנים"), + ("sync-clipboard-between-sessions-tip", "טקסט או תמונות שהועתקו בסשן מרוחק אחד נשלחים גם ללוח של שאר הסשנים המחוברים שלך."), ].iter().cloned().collect(); } diff --git a/src/lang/hi.rs b/src/lang/hi.rs index da7fc6a40..f9eebc603 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "जारी रखें"), ("Browser didn't open? Use the url below to sign in.", "ब्राउज़र नहीं खुला? लॉगिन करने के लिए नीचे दिए गए URL का उपयोग करें।"), ("Lock canvas", "कैनवास लॉक करें"), + ("Sync clipboard between sessions", "सत्रों के बीच क्लिपबोर्ड सिंक करें"), + ("sync-clipboard-between-sessions-tip", "एक रिमोट सत्र में कॉपी किए गए टेक्स्ट या चित्र आपके अन्य जुड़े सत्रों के क्लिपबोर्ड पर भी भेजे जाते हैं।"), ].iter().cloned().collect(); } diff --git a/src/lang/hr.rs b/src/lang/hr.rs index 20f5b0da1..a793e519e 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Nastavi"), ("Browser didn't open? Use the url below to sign in.", "Preglednik se nije otvorio? Za prijavu upotrijebite URL u nastavku."), ("Lock canvas", "Zaključaj pozadinu"), + ("Sync clipboard between sessions", "Sinkroniziraj međuspremnik između sesija"), + ("sync-clipboard-between-sessions-tip", "Tekst ili slike kopirani u jednoj udaljenoj sesiji šalju se i u međuspremnik vaših ostalih povezanih sesija."), ].iter().cloned().collect(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 28f3d0482..4a5a737da 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Folytatás"), ("Browser didn't open? Use the url below to sign in.", "Nem nyílt meg a böngésző? A belépéshez használja az alábbi URL-címet."), ("Lock canvas", "Nézet zárolása"), + ("Sync clipboard between sessions", "Vágólap szinkronizálása a munkamenetek között"), + ("sync-clipboard-between-sessions-tip", "Az egyik távoli munkamenetben másolt szöveg vagy kép a többi csatlakoztatott munkamenet vágólapjára is elküldésre kerül."), ].iter().cloned().collect(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index 8c7af75e4..f5a8918bd 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Lanjutkan"), ("Browser didn't open? Use the url below to sign in.", "Browser tidak terbuka? Gunakan URL di bawah ini untuk masuk."), ("Lock canvas", "Kunci kanvas"), + ("Sync clipboard between sessions", "Sinkronkan papan klip antar sesi"), + ("sync-clipboard-between-sessions-tip", "Teks atau gambar yang disalin di satu sesi jarak jauh juga dikirim ke papan klip sesi terhubung Anda yang lain."), ].iter().cloned().collect(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index 8de645fbe..73e5e6d83 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Continua"), ("Browser didn't open? Use the url below to sign in.", "Il browser non si è aperto? Usa l'URL qui sotto per accedere."), ("Lock canvas", "Blocca tela"), + ("Sync clipboard between sessions", "Sincronizza gli appunti tra le sessioni"), + ("sync-clipboard-between-sessions-tip", "Il testo o le immagini copiati in una sessione remota vengono inviati anche agli appunti delle altre sessioni connesse."), ].iter().cloned().collect(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index 71713ca01..dad7b0557 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "続行"), ("Browser didn't open? Use the url below to sign in.", "ブラウザが開きませんでしたか?下記の URL からログインしてください。"), ("Lock canvas", "キャンバスをロック"), + ("Sync clipboard between sessions", "セッション間でクリップボードを同期"), + ("sync-clipboard-between-sessions-tip", "1つのリモートセッションでコピーしたテキストや画像は、接続中の他のセッションのクリップボードにも送信されます。"), ].iter().cloned().collect(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index abd48fb9e..2151c9b8b 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "계속"), ("Browser didn't open? Use the url below to sign in.", "브라우저가 열리지 않았나요? 아래 URL로 로그인하세요."), ("Lock canvas", "캔버스 잠금"), + ("Sync clipboard between sessions", "세션 간 클립보드 동기화"), + ("sync-clipboard-between-sessions-tip", "하나의 원격 세션에서 복사한 텍스트나 이미지는 연결된 다른 세션의 클립보드에도 전송됩니다."), ].iter().cloned().collect(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index b623e5c33..27ed4e8d6 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Жалғастыру"), ("Browser didn't open? Use the url below to sign in.", "Браузер ашылмады ма? Кіру үшін төмендегі сілтемені пайдаланыңыз."), ("Lock canvas", "Кенепті құлыптау"), + ("Sync clipboard between sessions", "Сеанстар арасында көшіру-тақтасын синхрондау"), + ("sync-clipboard-between-sessions-tip", "Бір қашықтағы сеанста көшірілген мәтін немесе суреттер басқа қосылған сеанстардың көшіру-тақтасына да жіберіледі."), ].iter().cloned().collect(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 45d2ddc08..4beb66593 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Tęsti"), ("Browser didn't open? Use the url below to sign in.", "Naršyklė neatsidarė? Prisijunkite naudodami toliau pateiktą URL."), ("Lock canvas", "Užrakinti drobę"), + ("Sync clipboard between sessions", "Sinchronizuoti iškarpinę tarp seansų"), + ("sync-clipboard-between-sessions-tip", "Viename nuotoliniame seanse nukopijuotas tekstas ar vaizdai taip pat siunčiami į kitų prijungtų seansų iškarpinę."), ].iter().cloned().collect(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index a71cdc038..2d7038f19 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Turpināt"), ("Browser didn't open? Use the url below to sign in.", "Pārlūkprogramma neatvērās? Izmantojiet tālāk norādīto URL, lai pieslēgtos."), ("Lock canvas", "Bloķēt audeklu"), + ("Sync clipboard between sessions", "Sinhronizēt starpliktuvi starp sesijām"), + ("sync-clipboard-between-sessions-tip", "Vienā attālajā sesijā nokopētais teksts vai attēli tiek nosūtīti arī uz pārējo pievienoto sesiju starpliktuvi."), ].iter().cloned().collect(); } diff --git a/src/lang/ml.rs b/src/lang/ml.rs index 157a7abb3..f7804fa0c 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "തുടരുക"), ("Browser didn't open? Use the url below to sign in.", "ബ്രൗസർ തുറന്നില്ലേ? ലോഗിൻ ചെയ്യാൻ താഴെയുള്ള URL ഉപയോഗിക്കുക."), ("Lock canvas", "ക്യാൻവാസ് ലോക്ക് ചെയ്യുക"), + ("Sync clipboard between sessions", "സെഷനുകൾക്കിടയിൽ ക്ലിപ്പ്ബോർഡ് സമന്വയിപ്പിക്കുക"), + ("sync-clipboard-between-sessions-tip", "ഒരു റിമോട്ട് സെഷനിൽ പകർത്തിയ ടെക്സ്റ്റോ ചിത്രങ്ങളോ നിങ്ങളുടെ മറ്റ് കണക്റ്റുചെയ്ത സെഷനുകളുടെ ക്ലിപ്പ്ബോർഡിലേക്കും അയയ്ക്കപ്പെടും."), ].iter().cloned().collect(); } diff --git a/src/lang/nb.rs b/src/lang/nb.rs index e92c47eb2..753db92a2 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Fortsett"), ("Browser didn't open? Use the url below to sign in.", "Åpnet ikke nettleseren? Bruk URL-en nedenfor for å logge inn."), ("Lock canvas", "Lås lerret"), + ("Sync clipboard between sessions", "Synkroniser utklippstavlen mellom økter"), + ("sync-clipboard-between-sessions-tip", "Tekst eller bilder som kopieres i én ekstern økt, sendes også til utklippstavlen i de andre tilkoblede øktene dine."), ].iter().cloned().collect(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 813bb2110..7a575a04c 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Doorgaan"), ("Browser didn't open? Use the url below to sign in.", "Is de browser niet geopend? Gebruik onderstaande URL om in te loggen."), ("Lock canvas", "Canvas vergrendelen"), + ("Sync clipboard between sessions", "Klembord synchroniseren tussen sessies"), + ("sync-clipboard-between-sessions-tip", "Tekst of afbeeldingen die in één externe sessie worden gekopieerd, worden ook naar het klembord van uw andere verbonden sessies gestuurd."), ].iter().cloned().collect(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 37144a0bc..8b599b2ea 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Kontynuuj"), ("Browser didn't open? Use the url below to sign in.", "Przeglądarka się nie otworzyła? Użyj poniższego adresu URL, aby się zalogować."), ("Lock canvas", "Zablokuj ekran"), + ("Sync clipboard between sessions", "Synchronizuj schowek między sesjami"), + ("sync-clipboard-between-sessions-tip", "Tekst lub obrazy skopiowane w jednej sesji zdalnej są wysyłane także do schowka pozostałych połączonych sesji."), ].iter().cloned().collect(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index 94043d38d..2b71cc3e2 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Continuar"), ("Browser didn't open? Use the url below to sign in.", "O navegador não abriu? Utilize o URL abaixo para iniciar sessão."), ("Lock canvas", "Bloquear tela"), + ("Sync clipboard between sessions", "Sincronizar área de transferência entre sessões"), + ("sync-clipboard-between-sessions-tip", "O texto ou as imagens copiados numa sessão remota também são enviados para a área de transferência das suas outras sessões ligadas."), ].iter().cloned().collect(); } diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index a7879960d..892358a79 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Continuar"), ("Browser didn't open? Use the url below to sign in.", "O navegador não foi aberto? Use a URL abaixo para fazer login."), ("Lock canvas", "Bloquear tela"), + ("Sync clipboard between sessions", "Sincronizar área de transferência entre sessões"), + ("sync-clipboard-between-sessions-tip", "Texto ou imagens copiados em uma sessão remota também são enviados para a área de transferência das suas outras sessões conectadas."), ].iter().cloned().collect(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index 03eb282f5..6bf368822 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Continuă"), ("Browser didn't open? Use the url below to sign in.", "Browserul nu s-a deschis? Folosește URL-ul de mai jos pentru a te conecta."), ("Lock canvas", "Blochează ecranul"), + ("Sync clipboard between sessions", "Sincronizează clipboardul între sesiuni"), + ("sync-clipboard-between-sessions-tip", "Textul sau imaginile copiate într-o sesiune la distanță sunt trimise și în clipboardul celorlalte sesiuni conectate."), ].iter().cloned().collect(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 8d29105db..54d388cbe 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Продолжить"), ("Browser didn't open? Use the url below to sign in.", "Браузер не открылся? Используйте ссылку ниже для входа."), ("Lock canvas", "Заблокировать холст"), + ("Sync clipboard between sessions", "Синхронизировать буфер обмена между сеансами"), + ("sync-clipboard-between-sessions-tip", "Текст или изображения, скопированные в одном удалённом сеансе, также отправляются в буфер обмена других подключённых сеансов."), ].iter().cloned().collect(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 8be034c4b..e13c07fc9 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Sighi"), ("Browser didn't open? Use the url below to sign in.", "Non s'est abertu su navigadore? Imprea s'URL inoghe in suta pro intrare."), ("Lock canvas", "Bloca sa tela"), + ("Sync clipboard between sessions", "Sincroniza sa punta de billete intre is sessiones"), + ("sync-clipboard-between-sessions-tip", "Su testu o is immàgines copiadas in una sessione remota sunt imbiadas fintzas a sa punta de billete de is àteras sessiones connètidas."), ].iter().cloned().collect(); } diff --git a/src/lang/sk.rs b/src/lang/sk.rs index cf17d2129..1795c97ac 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Pokračovať"), ("Browser didn't open? Use the url below to sign in.", "Neotvoril sa prehliadač? Na prihlásenie použite URL nižšie."), ("Lock canvas", "Uzamknúť zobrazenie"), + ("Sync clipboard between sessions", "Synchronizovať schránku medzi reláciami"), + ("sync-clipboard-between-sessions-tip", "Text alebo obrázky skopírované v jednej vzdialenej relácii sa odošlú aj do schránky ostatných pripojených relácií."), ].iter().cloned().collect(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs index 6d4480fe5..694297a46 100644 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Nadaljuj"), ("Browser didn't open? Use the url below to sign in.", "Brskalnik se ni odprl? Za prijavo uporabite spodnji URL."), ("Lock canvas", "Zakleni platno"), + ("Sync clipboard between sessions", "Sinhroniziraj odložišče med sejami"), + ("sync-clipboard-between-sessions-tip", "Besedilo ali slike, kopirane v eni oddaljeni seji, se pošljejo tudi v odložišče vaših drugih povezanih sej."), ].iter().cloned().collect(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 470708082..3263993f3 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Vazhdo"), ("Browser didn't open? Use the url below to sign in.", "Shfletuesi nuk u hap? Përdorni URL-në më poshtë për të hyrë."), ("Lock canvas", "Kyç canvas"), + ("Sync clipboard between sessions", "Sinkronizo clipboard-in midis sesioneve"), + ("sync-clipboard-between-sessions-tip", "Teksti ose imazhet e kopjuara në një sesion të largët dërgohen edhe në clipboard-in e sesioneve të tjera të lidhura."), ].iter().cloned().collect(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index fe3d047e5..31987bf31 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Nastavi"), ("Browser didn't open? Use the url below to sign in.", "Pregledač se nije otvorio? Za prijavu koristite URL ispod."), ("Lock canvas", "Zaključaj pozadinu"), + ("Sync clipboard between sessions", "Sinhronizuj klipbord između sesija"), + ("sync-clipboard-between-sessions-tip", "Tekst ili slike kopirane u jednoj udaljenoj sesiji šalju se i u klipbord vaših ostalih povezanih sesija."), ].iter().cloned().collect(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 9f2efc263..45cc4f030 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Fortsätt"), ("Browser didn't open? Use the url below to sign in.", "Öppnades inte webbläsaren? Använd URL:en nedan för att logga in."), ("Lock canvas", "Lås canvas"), + ("Sync clipboard between sessions", "Synkronisera urklipp mellan sessioner"), + ("sync-clipboard-between-sessions-tip", "Text eller bilder som kopieras i en fjärrsession skickas även till urklipp i dina andra anslutna sessioner."), ].iter().cloned().collect(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs index ac2486ccb..2a0e1e0f7 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "தொடர்க"), ("Browser didn't open? Use the url below to sign in.", "உலாவி திறக்கவில்லையா? உள்நுழைய கீழே உள்ள URL ஐப் பயன்படுத்தவும்."), ("Lock canvas", "கேன்வாஸைப் பூட்டு"), + ("Sync clipboard between sessions", "அமர்வுகளுக்கு இடையே கிளிப்போர்டை ஒத்திசைக்கவும்"), + ("sync-clipboard-between-sessions-tip", "ஒரு தொலை அமர்வில் நகலெடுக்கப்பட்ட உரை அல்லது படங்கள் உங்கள் பிற இணைக்கப்பட்ட அமர்வுகளின் கிளிப்போர்டுக்கும் அனுப்பப்படும்."), ].iter().cloned().collect(); } diff --git a/src/lang/template.rs b/src/lang/template.rs index b65c92793..feab1b71e 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), ("Lock canvas", ""), + ("Sync clipboard between sessions", ""), + ("sync-clipboard-between-sessions-tip", ""), ].iter().cloned().collect(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index 7531d072d..d261884b3 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "ดำเนินการต่อ"), ("Browser didn't open? Use the url below to sign in.", "เบราว์เซอร์ไม่เปิดใช่ไหม? ใช้ URL ด้านล่างเพื่อเข้าสู่ระบบ"), ("Lock canvas", "ล็อคแคนวาส"), + ("Sync clipboard between sessions", "ซิงค์คลิปบอร์ดระหว่างเซสชัน"), + ("sync-clipboard-between-sessions-tip", "ข้อความหรือรูปภาพที่คัดลอกในเซสชันระยะไกลหนึ่งจะถูกส่งไปยังคลิปบอร์ดของเซสชันอื่นที่เชื่อมต่ออยู่ด้วย"), ].iter().cloned().collect(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 8546d96bc..4e5f1bbca 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Devam et"), ("Browser didn't open? Use the url below to sign in.", "Tarayıcı açılmadı mı? Giriş yapmak için aşağıdaki URL'yi kullanın."), ("Lock canvas", "Tuvali kilitle"), + ("Sync clipboard between sessions", "Oturumlar arasında panoyu senkronize et"), + ("sync-clipboard-between-sessions-tip", "Bir uzak oturumda kopyalanan metin veya görseller, bağlı diğer oturumlarınızın panosuna da gönderilir."), ].iter().cloned().collect(); } diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 8663c5d5f..75639e65d 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "繼續"), ("Browser didn't open? Use the url below to sign in.", "瀏覽器未開啟?請使用下方網址登入。"), ("Lock canvas", "鎖定畫布"), + ("Sync clipboard between sessions", "在工作階段間同步剪貼簿"), + ("sync-clipboard-between-sessions-tip", "在一個遠端工作階段中複製的文字或圖片也會傳送到其他已連線工作階段的剪貼簿。"), ].iter().cloned().collect(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index c11eac1f6..97c56ba86 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Продовжити"), ("Browser didn't open? Use the url below to sign in.", "Браузер не відкрився? Скористайтеся посиланням нижче, щоб увійти."), ("Lock canvas", "Блокування полотна"), + ("Sync clipboard between sessions", "Синхронізувати буфер обміну між сеансами"), + ("sync-clipboard-between-sessions-tip", "Текст або зображення, скопійовані в одному віддаленому сеансі, також надсилаються до буфера обміну інших підключених сеансів."), ].iter().cloned().collect(); } diff --git a/src/lang/vi.rs b/src/lang/vi.rs index 7f4e0ef46..8995ade87 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -761,5 +761,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Continue", "Tiếp tục"), ("Browser didn't open? Use the url below to sign in.", "Trình duyệt không mở được? Hãy dùng URL bên dưới để đăng nhập."), ("Lock canvas", "Khóa khung hình"), + ("Sync clipboard between sessions", "Đồng bộ clipboard giữa các phiên"), + ("sync-clipboard-between-sessions-tip", "Văn bản hoặc hình ảnh được sao chép trong một phiên từ xa cũng được gửi đến clipboard của các phiên đã kết nối khác."), ].iter().cloned().collect(); }