//! Customization page — produces a signed `custom.txt` blob the patched //! RustDesk client accepts at startup. The client's `read_custom_client` //! decodes the file as standard base64, verifies it against the embedded //! branding public key (Ed25519 / sodium `sign::verify`), and applies the //! contained JSON: `app-name`, `default-settings`, `override-settings`, //! `app-icon` (base64 PNG/ICO/JPEG), plus arbitrary string keys that map //! into the client's various settings registries. //! //! Distinct from the Deploy page (`deploy.rs`), which produces *unsigned* //! `CustomServer` blobs for embedding in the installer filename. That blob //! travels in a different channel and uses URL_SAFE_NO_PAD base64 — do not //! reuse `encode_blob` from there. use super::shared::{html_escape, notice_html, require_admin}; use crate::api::error::ApiError; use crate::api::middleware::AuthedUser; use axum::extract::{Form, Multipart}; use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use serde::Deserialize; use serde_json::{json, Map, Value}; use sodiumoxide::crypto::sign; /// Hard cap on uploaded logo bytes (raw, before base64). 256 KiB is enough /// for a 256x256 PNG with room to spare; base64 inflation puts the JSON /// payload around 340 KiB which the signed blob comfortably absorbs. const MAX_LOGO_BYTES: usize = 256 * 1024; pub async fn index(admin: AuthedUser) -> Result, ApiError> { require_admin(&admin)?; let pubkey = crate::common::read_branding_pubkey(); Ok(Html(render_form(&FormState::default(), &pubkey, "", None))) } pub async fn generate( admin: AuthedUser, mut multipart: Multipart, ) -> Result, ApiError> { require_admin(&admin)?; let pubkey = crate::common::read_branding_pubkey(); let mut state = FormState::default(); let mut logo_bytes: Option> = None; let mut logo_too_big = false; let mut logo_bad_format = false; while let Some(field) = multipart .next_field() .await .map_err(|e| ApiError::BadRequest(format!("multipart parse: {e}")))? { let name = field.name().unwrap_or("").to_string(); match name.as_str() { "app_name" => { state.app_name = field.text().await.unwrap_or_default(); } "default_settings_json" => { state.default_settings_json = field.text().await.unwrap_or_default(); } "override_settings_json" => { state.override_settings_json = field.text().await.unwrap_or_default(); } "kv_key" => { state.kv_keys.push(field.text().await.unwrap_or_default()); } "kv_value" => { state.kv_values.push(field.text().await.unwrap_or_default()); } "logo" => { let bytes = field.bytes().await.unwrap_or_default(); if bytes.is_empty() { continue; } if bytes.len() > MAX_LOGO_BYTES { logo_too_big = true; continue; } if !is_supported_image(&bytes) { logo_bad_format = true; continue; } logo_bytes = Some(bytes.to_vec()); } _ => { let _ = field.bytes().await; } } } if state.app_name.trim().is_empty() { return Ok(Html(render_form( &state, &pubkey, "", Some(("error", "App name is required.")), ))); } if logo_too_big { return Ok(Html(render_form( &state, &pubkey, "", Some(( "error", "Logo exceeds 256 KiB. Please upload a smaller PNG/ICO/JPEG.", )), ))); } if logo_bad_format { return Ok(Html(render_form( &state, &pubkey, "", Some(( "error", "Logo must be a PNG, ICO, or JPEG image (detected by magic bytes).", )), ))); } let default_obj = match parse_settings_json(&state.default_settings_json) { Ok(o) => o, Err(msg) => { return Ok(Html(render_form( &state, &pubkey, "", Some(("error", &format!("default-settings: {msg}"))), ))) } }; let override_obj = match parse_settings_json(&state.override_settings_json) { Ok(o) => o, Err(msg) => { return Ok(Html(render_form( &state, &pubkey, "", Some(("error", &format!("override-settings: {msg}"))), ))) } }; let Some(sk) = crate::common::read_branding_sk() else { return Ok(Html(render_form( &state, &pubkey, "", Some(( "error", "branding_ed25519 not found or malformed on the server. Restart hbbs to regenerate.", )), ))); }; let mut payload: Map = Map::new(); payload.insert("app-name".into(), json!(state.app_name)); if !default_obj.is_empty() { payload.insert("default-settings".into(), Value::Object(default_obj)); } if !override_obj.is_empty() { payload.insert("override-settings".into(), Value::Object(override_obj)); } for (k, v) in collect_kv_pairs(&state.kv_keys, &state.kv_values) { payload.insert(k, json!(v)); } if let Some(bytes) = &logo_bytes { payload.insert("app-icon".into(), json!(base64::encode(bytes))); } let body = match serde_json::to_vec(&Value::Object(payload)) { Ok(v) => v, Err(e) => { return Ok(Html(render_form( &state, &pubkey, "", Some(("error", &format!("json encode: {e}"))), ))) } }; let signed = sign::sign(&body, &sk); let blob = base64::encode(&signed); let result = render_result(&blob, logo_bytes.as_deref()); Ok(Html(render_form(&state, &pubkey, &result, None))) } #[derive(Debug, Deserialize)] pub struct DownloadForm { pub blob: String, } pub async fn download(admin: AuthedUser, Form(f): Form) -> Result { require_admin(&admin)?; if f.blob.trim().is_empty() { return Err(ApiError::BadRequest("blob is empty".into())); } let mut resp = (StatusCode::OK, f.blob).into_response(); let h = resp.headers_mut(); h.insert( header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"), ); h.insert( header::CONTENT_DISPOSITION, HeaderValue::from_static(r#"attachment; filename="custom.txt""#), ); Ok(resp) } // ---------- helpers ---------- #[derive(Default)] struct FormState { app_name: String, default_settings_json: String, override_settings_json: String, kv_keys: Vec, kv_values: Vec, } fn parse_settings_json(raw: &str) -> Result, String> { let trimmed = raw.trim(); if trimmed.is_empty() { return Ok(Map::new()); } let parsed: Value = serde_json::from_str(trimmed).map_err(|e| e.to_string())?; match parsed { Value::Object(m) => Ok(m), _ => Err("must be a JSON object".into()), } } fn collect_kv_pairs(keys: &[String], values: &[String]) -> Vec<(String, String)> { keys.iter() .zip(values.iter()) .filter_map(|(k, v)| { let k = k.trim(); if k.is_empty() { None } else { Some((k.to_string(), v.clone())) } }) .collect() } /// Magic-byte sniff. Don't trust the multipart Content-Type — clients can /// lie. PNG, JPEG (any variant), and ICO cover the formats the Flutter /// `Image.memory` widget renders without a decoder plugin. fn is_supported_image(bytes: &[u8]) -> bool { if bytes.len() < 4 { return false; } let png = bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]); let jpeg = bytes.starts_with(&[0xFF, 0xD8, 0xFF]); let ico = bytes.starts_with(&[0x00, 0x00, 0x01, 0x00]); png || jpeg || ico } fn render_form( state: &FormState, pubkey: &str, result_html: &str, notice: Option<(&str, &str)>, ) -> String { let notice_block = match notice { Some((kind, msg)) => notice_html(kind, msg), None => String::new(), }; let pubkey_display = if pubkey.is_empty() { r#"branding_ed25519.pub not found on the server. Restart hbbs to generate it."#.to_string() } else { format!( r#"{}"#, html_escape(pubkey) ) }; let default_settings_default = if state.default_settings_json.is_empty() { "{}" } else { &state.default_settings_json }; let override_settings_default = if state.override_settings_json.is_empty() { "{}" } else { &state.override_settings_json }; let kv_rows = render_kv_rows(&state.kv_keys, &state.kv_values); format!( r##"

Customization

Generate a signed custom.txt the patched RustDesk client picks up at startup. The client must be built with this server's branding public key embedded (see below). Drop the resulting custom.txt next to rustdesk.exe on Windows, in RustDesk.app/Contents/Resources/ on macOS, or alongside the Linux binary.

Branding public key

{pubkey_display}

Bake this into your client build with RUSTDESK_BRANDING_PUBKEY=$(cat branding_ed25519.pub) cargo build --release. Stock RustDesk clients will silently ignore the resulting custom.txt.

{notice_block}

Replaces "RustDesk" in window titles, the about dialog, and other in-app surfaces. Setting this flips is_custom_client() on the client.

Rendered in the connection page header, the about dialog, and the system tray when the patched client supports it. OS-level taskbar/dock icons are unaffected (those need build-time resource patching).

Defaults applied on first launch — user can still change these. Example: {{"theme": "dark"}}

Forced settings — user cannot change these. Example: {{"hide-powered-by-me": "Y", "force-always-relay": "Y"}}

Top-level keys merged into the signed JSON. Useful for buildin flags like hide-tray, preset-user-name, etc.

{kv_rows}
{result_html}
"##, notice_block = notice_block, pubkey_display = pubkey_display, app_name = html_escape(&state.app_name), default_settings = html_escape(default_settings_default), override_settings = html_escape(override_settings_default), kv_rows = kv_rows, result_html = result_html, ) } fn render_kv_rows(keys: &[String], values: &[String]) -> String { if keys.is_empty() { return String::new(); } keys.iter() .zip(values.iter().chain(std::iter::repeat(&String::new()))) .map(|(k, v)| { format!( r##"
"##, k = html_escape(k), v = html_escape(v), ) }) .collect::>() .join("\n") } fn render_result(blob: &str, logo_bytes: Option<&[u8]>) -> String { let preview = match logo_bytes { Some(bytes) if !bytes.is_empty() => { let mime = sniff_mime(bytes); format!( r##"

Logo preview

logo
"##, mime = mime, b64 = base64::encode(bytes), ) } _ => String::new(), }; format!( r##"

Signed customization blob

Save the blob below as custom.txt and drop it next to the patched RustDesk binary.

{preview}
{blob}
Where to drop custom.txt
  • Windows: C:\Program Files\RustDesk\custom.txt
  • macOS: /Applications/RustDesk.app/Contents/Resources/custom.txt
  • Linux: alongside the binary (e.g. /usr/lib/rustdesk/custom.txt)

The file is read once at process start; restart the client (or its tray service) after dropping it.

"##, preview = preview, blob = html_escape(blob), blob_attr = html_escape(blob), ) } fn sniff_mime(bytes: &[u8]) -> &'static str { if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) { "image/png" } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { "image/jpeg" } else if bytes.starts_with(&[0x00, 0x00, 0x01, 0x00]) { "image/x-icon" } else { "application/octet-stream" } }