feat(console): add Client Branding for desktop remotes

Admin page and Go /api/branding schema v1 with logo/contact fields,
RustDesk-safe heartbeat projections, i18n, and handler tests.

Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
This commit is contained in:
UNITRONIX
2026-09-06 12:26:29 +02:00
parent 86f7046598
commit b73648651d
38 changed files with 1665 additions and 83 deletions
+1 -1
View File
@@ -1171,7 +1171,7 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
path == "/api/login" || path == "/api/login-options" || path == "/api/logout" ||
path == "/api/oidc/auth" || path == "/api/oidc/auth-query" || path == "/api/oidc/callback" ||
path == "/api/heartbeat" || path == "/api/sysinfo" || path == "/api/sysinfo_ver" ||
path == "/api/branding" ||
(path == "/api/branding" && r.Method == http.MethodGet) ||
path == "/api/server-key" || path == "/api/server-key/fingerprint" ||
path == "/api/software" || path == "/api/software/client-download-link" ||
path == "/api/audit/conn" && r.Method == http.MethodPost ||
+245 -33
View File
@@ -6,9 +6,11 @@ import (
"fmt"
"log"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/unitronix/betterdesk-server/db"
"github.com/unitronix/betterdesk-server/events"
@@ -18,13 +20,51 @@ import (
// Branding configuration — served to desktop clients (public, no auth)
// ---------------------------------------------------------------------------
const (
brandingSchemaVersion = 1
brandingMaxFieldLen = 256
brandingMaxWebsiteLen = 512
brandingMaxLogoBytes = 512 * 1024
)
// BrandingLogo is an optional image payload for BetterDesk desktop clients.
type BrandingLogo struct {
Mime string `json:"mime,omitempty"`
DataBase64 string `json:"data_base64,omitempty"`
URL string `json:"url,omitempty"`
}
// BrandingBetterDeskProfile lists fields BetterDesk clients should apply.
type BrandingBetterDeskProfile struct {
Apply []string `json:"apply"`
}
// BrandingRustDeskProfile is the heartbeat-safe subset for stock RustDesk.
type BrandingRustDeskProfile struct {
ConfigOptions map[string]string `json:"config_options,omitempty"`
}
// BrandingProfiles separates BetterDesk-rich and RustDesk-compatible projections.
type BrandingProfiles struct {
BetterDesk BrandingBetterDeskProfile `json:"betterdesk"`
RustDesk BrandingRustDeskProfile `json:"rustdesk"`
}
// BrandingConfig is the payload returned by GET /api/branding.
// Desktop clients fetch this to apply company theming.
// Legacy fields are preserved for older clients / enrollment payloads.
type BrandingConfig struct {
SchemaVersion int `json:"schema_version"`
Revision string `json:"revision"`
CompanyName string `json:"company_name"`
Phone string `json:"phone,omitempty"`
Email string `json:"email,omitempty"`
Website string `json:"website,omitempty"`
AccentColor string `json:"accent_color"`
SupportContact string `json:"support_contact"`
Logo *BrandingLogo `json:"logo,omitempty"`
Colors map[string]string `json:"colors,omitempty"`
Profiles BrandingProfiles `json:"profiles"`
SyncModes []SyncModeOption `json:"sync_modes"`
}
@@ -41,21 +81,51 @@ var defaultSyncModes = []SyncModeOption{
{ID: "turbo", Label: "Turbo", Description: "Aggressive — 10s telemetry, 1min disk, 30min software"},
}
// handleGetBranding returns the branding configuration.
// Public endpoint — no authentication required.
// GET /api/branding
func (s *Server) handleGetBranding(w http.ResponseWriter, r *http.Request) {
var (
accentColorRegexp = regexp.MustCompile(`(?i)^#([0-9a-f]{6}|[0-9a-f]{3})$`)
allowedLogoMimes = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/jpg": true,
"image/webp": true,
}
)
func defaultBetterDeskApply() []string {
return []string{"company_name", "phone", "email", "website", "logo", "accent_color"}
}
// loadBrandingConfig reads Client Branding from server_config with safe defaults.
func (s *Server) loadBrandingConfig() BrandingConfig {
cfg := BrandingConfig{
SchemaVersion: brandingSchemaVersion,
Revision: "0",
CompanyName: "BetterDesk",
AccentColor: "#4f6ef7",
SupportContact: "",
SyncModes: defaultSyncModes,
Profiles: BrandingProfiles{
BetterDesk: BrandingBetterDeskProfile{Apply: defaultBetterDeskApply()},
RustDesk: BrandingRustDeskProfile{ConfigOptions: map[string]string{}},
},
}
if s == nil || s.db == nil {
return cfg
}
// Load overrides from server_config
if v, err := s.db.GetConfig("branding_company_name"); err == nil && v != "" {
cfg.CompanyName = v
}
if v, err := s.db.GetConfig("branding_phone"); err == nil && v != "" {
cfg.Phone = v
}
if v, err := s.db.GetConfig("branding_email"); err == nil && v != "" {
cfg.Email = v
}
if v, err := s.db.GetConfig("branding_website"); err == nil && v != "" {
cfg.Website = v
}
if v, err := s.db.GetConfig("branding_accent_color"); err == nil && v != "" {
cfg.AccentColor = v
}
@@ -68,7 +138,116 @@ func (s *Server) handleGetBranding(w http.ResponseWriter, r *http.Request) {
cfg.Colors = colors
}
}
if v, err := s.db.GetConfig("branding_logo"); err == nil && v != "" {
var logo BrandingLogo
if json.Unmarshal([]byte(v), &logo) == nil && (logo.DataBase64 != "" || logo.URL != "") {
cfg.Logo = &logo
}
}
if v, err := s.db.GetConfig("branding_revision"); err == nil && v != "" {
cfg.Revision = v
}
cfg.Profiles.RustDesk.ConfigOptions = rustDeskConfigOptionsFromBranding(cfg)
return cfg
}
func rustDeskConfigOptionsFromBranding(cfg BrandingConfig) map[string]string {
out := map[string]string{}
name := strings.TrimSpace(cfg.CompanyName)
if name != "" && !strings.EqualFold(name, "BetterDesk") {
out["display-name"] = name
}
return out
}
func brandingRevisionMillis(cfg BrandingConfig) int64 {
if cfg.Revision == "" || cfg.Revision == "0" {
return 0
}
if ms, err := strconv.ParseInt(cfg.Revision, 10, 64); err == nil {
return ms
}
return 0
}
func stripHTMLLike(s string) string {
s = strings.ReplaceAll(s, "<", "")
s = strings.ReplaceAll(s, ">", "")
return strings.TrimSpace(s)
}
func validateAccentColor(color string) error {
color = strings.TrimSpace(color)
if color == "" {
return nil
}
if !accentColorRegexp.MatchString(color) {
return fmt.Errorf("accent_color must be #RGB or #RRGGBB")
}
return nil
}
func validateBrandingLogo(logo *BrandingLogo) error {
if logo == nil {
return nil
}
logo.Mime = strings.ToLower(strings.TrimSpace(logo.Mime))
logo.DataBase64 = strings.TrimSpace(logo.DataBase64)
logo.URL = strings.TrimSpace(logo.URL)
if logo.DataBase64 == "" && logo.URL == "" {
return nil
}
if logo.DataBase64 != "" {
if logo.Mime == "" {
return fmt.Errorf("logo.mime is required with data_base64")
}
if !allowedLogoMimes[logo.Mime] {
return fmt.Errorf("logo.mime must be image/png, image/jpeg, or image/webp")
}
raw, err := base64.StdEncoding.DecodeString(logo.DataBase64)
if err != nil {
// Accept URL-safe / raw without padding variants used by browsers.
raw, err = base64.RawStdEncoding.DecodeString(logo.DataBase64)
if err != nil {
return fmt.Errorf("logo.data_base64 is invalid")
}
}
if len(raw) == 0 || len(raw) > brandingMaxLogoBytes {
return fmt.Errorf("logo must be between 1 byte and 512 KiB")
}
logo.URL = "" // prefer embedded payload when both present
}
if logo.URL != "" {
lower := strings.ToLower(logo.URL)
if !strings.HasPrefix(lower, "https://") && !strings.HasPrefix(lower, "http://") {
return fmt.Errorf("logo.url must be http(s)")
}
if strings.ContainsAny(logo.URL, "<>\"'") {
return fmt.Errorf("logo.url contains invalid characters")
}
if utf8.RuneCountInString(logo.URL) > brandingMaxWebsiteLen {
return fmt.Errorf("logo.url is too long")
}
}
return nil
}
func clipBrandingField(s string, max int) string {
s = stripHTMLLike(s)
if utf8.RuneCountInString(s) <= max {
return s
}
runes := []rune(s)
return string(runes[:max])
}
// handleGetBranding returns the branding configuration.
// Public endpoint — no authentication required.
// GET /api/branding
func (s *Server) handleGetBranding(w http.ResponseWriter, r *http.Request) {
cfg := s.loadBrandingConfig()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cfg)
}
@@ -78,36 +257,89 @@ func (s *Server) handleGetBranding(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSaveBranding(w http.ResponseWriter, r *http.Request) {
var req struct {
CompanyName *string `json:"company_name"`
Phone *string `json:"phone"`
Email *string `json:"email"`
Website *string `json:"website"`
AccentColor *string `json:"accent_color"`
SupportContact *string `json:"support_contact"`
Colors map[string]string `json:"colors"`
Logo *BrandingLogo `json:"logo"`
ClearLogo *bool `json:"clear_logo"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.AccentColor != nil {
if err := validateAccentColor(*req.AccentColor); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
if req.Logo != nil {
if err := validateBrandingLogo(req.Logo); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
if req.CompanyName != nil {
s.db.SetConfig("branding_company_name", *req.CompanyName)
s.db.SetConfig("branding_company_name", clipBrandingField(*req.CompanyName, brandingMaxFieldLen))
}
if req.Phone != nil {
s.db.SetConfig("branding_phone", clipBrandingField(*req.Phone, brandingMaxFieldLen))
}
if req.Email != nil {
s.db.SetConfig("branding_email", clipBrandingField(*req.Email, brandingMaxFieldLen))
}
if req.Website != nil {
s.db.SetConfig("branding_website", clipBrandingField(*req.Website, brandingMaxWebsiteLen))
}
if req.AccentColor != nil {
s.db.SetConfig("branding_accent_color", *req.AccentColor)
s.db.SetConfig("branding_accent_color", strings.TrimSpace(*req.AccentColor))
}
if req.SupportContact != nil {
s.db.SetConfig("branding_support_contact", *req.SupportContact)
s.db.SetConfig("branding_support_contact", clipBrandingField(*req.SupportContact, brandingMaxFieldLen))
}
if req.Colors != nil {
if data, err := json.Marshal(req.Colors); err == nil {
cleaned := make(map[string]string, len(req.Colors))
for k, v := range req.Colors {
k = clipBrandingField(k, 64)
v = clipBrandingField(v, 64)
if k == "" {
continue
}
cleaned[k] = v
}
if data, err := json.Marshal(cleaned); err == nil {
s.db.SetConfig("branding_colors", string(data))
}
}
if req.ClearLogo != nil && *req.ClearLogo {
_ = s.db.DeleteConfig("branding_logo")
} else if req.Logo != nil {
if req.Logo.DataBase64 == "" && req.Logo.URL == "" {
_ = s.db.DeleteConfig("branding_logo")
} else if data, err := json.Marshal(req.Logo); err == nil {
s.db.SetConfig("branding_logo", string(data))
}
}
revision := strconv.FormatInt(time.Now().UnixMilli(), 10)
s.db.SetConfig("branding_revision", revision)
if s.auditLog != nil {
s.auditLog.Log("branding_updated", s.remoteIP(r), getUsernameFromCtx(r), nil)
}
cfg := s.loadBrandingConfig()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"success": true})
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"revision": revision,
"branding": cfg,
})
}
// ---------------------------------------------------------------------------
@@ -1129,29 +1361,9 @@ func (s *Server) buildEnrollmentResponse(status, deviceID, syncMode, displayName
HeartbeatSec: 15,
}
// Inline branding
branding := &BrandingConfig{
CompanyName: "BetterDesk",
AccentColor: "#4f6ef7",
SupportContact: "",
SyncModes: defaultSyncModes,
}
if v, _ := s.db.GetConfig("branding_company_name"); v != "" {
branding.CompanyName = v
}
if v, _ := s.db.GetConfig("branding_accent_color"); v != "" {
branding.AccentColor = v
}
if v, _ := s.db.GetConfig("branding_support_contact"); v != "" {
branding.SupportContact = v
}
if v, _ := s.db.GetConfig("branding_colors"); v != "" {
var colors map[string]string
if json.Unmarshal([]byte(v), &colors) == nil {
branding.Colors = colors
}
}
resp.Branding = branding
// Inline branding (same source as GET /api/branding)
branding := s.loadBrandingConfig()
resp.Branding = &branding
// Server public key
if s.keyPair != nil {
@@ -0,0 +1,126 @@
package api
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"time"
"github.com/unitronix/betterdesk-server/config"
"github.com/unitronix/betterdesk-server/peer"
)
func TestBrandingGetDefaultsAndSave(t *testing.T) {
cfg := config.DefaultConfig()
database := testSetupDB(t)
defer database.Close()
cfg.APIPort = 19890
srv := New(cfg, database, peer.NewMap(), nil, "1.0.0-test")
if err := srv.Start(t.Context()); err != nil {
t.Fatal(err)
}
defer srv.Stop()
time.Sleep(80 * time.Millisecond)
base := fmt.Sprintf("http://127.0.0.1:%d/api", cfg.APIPort)
resp, err := http.Get(base + "/branding")
if err != nil {
t.Fatalf("GET /branding: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET status %d", resp.StatusCode)
}
var got BrandingConfig
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got.SchemaVersion != brandingSchemaVersion {
t.Fatalf("schema_version=%d", got.SchemaVersion)
}
if got.CompanyName != "BetterDesk" {
t.Fatalf("company=%q", got.CompanyName)
}
if len(got.Profiles.BetterDesk.Apply) == 0 {
t.Fatal("expected betterdesk apply list")
}
png1x1 := base64.StdEncoding.EncodeToString([]byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00,
0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xff, 0xff, 0x3f,
0x00, 0x05, 0xfe, 0x02, 0xfe, 0xdc, 0xcc, 0x59, 0xe7, 0x00, 0x00, 0x00,
0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
})
body := map[string]any{
"company_name": "Acme Corp",
"phone": "+48 111",
"email": "help@acme.example",
"website": "https://acme.example",
"accent_color": "#112233",
"support_contact": "Desk",
"logo": map[string]string{
"mime": "image/png",
"data_base64": png1x1,
},
}
raw, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPost, base+"/branding", bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req = testAuthReq(req)
saveResp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer saveResp.Body.Close()
if saveResp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(saveResp.Body)
t.Fatalf("POST status %d body=%s", saveResp.StatusCode, string(b))
}
resp2, err := http.Get(base + "/branding")
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
var got2 BrandingConfig
if err := json.NewDecoder(resp2.Body).Decode(&got2); err != nil {
t.Fatal(err)
}
if got2.CompanyName != "Acme Corp" || got2.Phone != "+48 111" || got2.Email != "help@acme.example" {
t.Fatalf("unexpected branding: %+v", got2)
}
if got2.Revision == "" || got2.Revision == "0" {
t.Fatal("expected non-zero revision")
}
if got2.Logo == nil || got2.Logo.DataBase64 == "" {
t.Fatal("expected logo payload")
}
if got2.Profiles.RustDesk.ConfigOptions["display-name"] != "Acme Corp" {
t.Fatalf("rustdesk profile: %+v", got2.Profiles.RustDesk)
}
}
func TestValidateAccentAndLogo(t *testing.T) {
t.Parallel()
if err := validateAccentColor("#abc"); err != nil {
t.Fatal(err)
}
if err := validateAccentColor("red"); err == nil {
t.Fatal("expected invalid color")
}
bad := &BrandingLogo{Mime: "image/gif", DataBase64: base64.StdEncoding.EncodeToString([]byte("x"))}
if err := validateBrandingLogo(bad); err == nil {
t.Fatal("expected gif rejected")
}
}
+41 -23
View File
@@ -849,27 +849,26 @@ func (s *Server) handleClientGroupPeers(w http.ResponseWriter, r *http.Request)
// handleClientHeartbeat accepts heartbeat pings from RustDesk clients.
// POST /api/heartbeat
// Request: { "id": "DEVICE_ID", "uuid": "...", "cpu": 42, "memory": 55, "disk": 30 }
// Response: { "modified_at": "2026-...", "sysinfo": true } (if sysinfo needed)
//
// { "modified_at": "2026-..." } (normal ACK)
// Request: { "id": "DEVICE_ID", "uuid": "...", "modified_at": 0, ... }
// Response: { "modified_at": <i64 ms>, "strategy"?: { "config_options": {...} }, "sysinfo"?: true }
func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) {
// BD-2026-001: Rate-limit heartbeat requests per IP
clientIP := s.remoteIP(r)
if !s.heartbeatLimiter.Allow(clientIP) {
writeJSON(w, http.StatusOK, map[string]string{"modified_at": time.Now().UTC().Format(time.RFC3339)})
writeJSON(w, http.StatusOK, map[string]any{"modified_at": s.clientBrandingModifiedAt()})
return
}
var body struct {
ID string `json:"id"`
UUID string `json:"uuid"`
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
Disk float64 `json:"disk"`
ID string `json:"id"`
UUID string `json:"uuid"`
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
Disk float64 `json:"disk"`
ModifiedAt int64 `json:"modified_at"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusOK, map[string]string{"modified_at": time.Now().UTC().Format(time.RFC3339)})
writeJSON(w, http.StatusOK, map[string]any{"modified_at": s.clientBrandingModifiedAt()})
return
}
@@ -878,19 +877,19 @@ func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) {
deviceID = body.UUID
}
if deviceID == "" || !peerIDRegexp.MatchString(deviceID) {
writeJSON(w, http.StatusOK, map[string]string{"modified_at": time.Now().UTC().Format(time.RFC3339)})
writeJSON(w, http.StatusOK, map[string]any{"modified_at": s.clientBrandingModifiedAt()})
return
}
// Verify peer exists
peer, err := s.db.GetPeer(deviceID)
if err != nil || peer == nil {
writeJSON(w, http.StatusOK, map[string]string{"modified_at": time.Now().UTC().Format(time.RFC3339)})
writeJSON(w, http.StatusOK, map[string]any{"modified_at": s.clientBrandingModifiedAt()})
return
}
if peer.Banned {
writeJSON(w, http.StatusOK, map[string]string{"error": "BANNED"})
writeJSON(w, http.StatusOK, map[string]any{"error": "BANNED", "modified_at": s.clientBrandingModifiedAt()})
return
}
@@ -907,18 +906,27 @@ func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) {
}
}
// Request sysinfo if hostname is empty (never received)
if peer.Hostname == "" {
writeJSON(w, http.StatusOK, map[string]any{
"modified_at": time.Now().UTC().Format(time.RFC3339),
"sysinfo": true,
})
return
serverModifiedAt := s.clientBrandingModifiedAt()
resp := map[string]any{
"modified_at": serverModifiedAt,
}
resp := map[string]any{
"modified_at": time.Now().UTC().Format(time.RFC3339),
// Request sysinfo if hostname is empty (never received)
if peer.Hostname == "" {
resp["sysinfo"] = true
}
// Push RustDesk-compatible branding subset when client cursor is stale.
if body.ModifiedAt != serverModifiedAt {
branding := s.loadBrandingConfig()
opts := branding.Profiles.RustDesk.ConfigOptions
if len(opts) > 0 {
resp["strategy"] = map[string]any{
"config_options": opts,
}
}
}
if policy, err := s.db.GetAccessPolicy(deviceID); err == nil && policy != nil {
resp["access_policy"] = map[string]any{
"unattended_enabled": policy.UnattendedEnabled,
@@ -929,6 +937,16 @@ func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// clientBrandingModifiedAt returns branding revision as Unix ms for heartbeat cursors.
func (s *Server) clientBrandingModifiedAt() int64 {
cfg := s.loadBrandingConfig()
if ms := brandingRevisionMillis(cfg); ms > 0 {
return ms
}
// Stable non-zero when no branding saved yet so clients can latch a cursor.
return 1
}
// handleClientSysinfo receives hardware/software info from RustDesk clients.
// POST /api/sysinfo
// Request: { "id": "DEVICE_ID", "hostname": "...", "platform": "...", "os": "...", "version": "..." ... }
+46
View File
@@ -0,0 +1,46 @@
# Client Branding
White-label profile for **desktop remote clients** connected to a BetterDesk server.
## Source of truth
- Stored in the **Go** API (`server_config` keys `branding_*`).
- Edited in the web console: **Main → Client Branding** (`/client-branding`).
- Distinct from **Settings → Branding** (console / RdClient appearance via `brandingService`).
## Public read API
`GET /api/branding` — no authentication (rate-limited).
Additive schema (`schema_version: 1`):
- Legacy: `company_name`, `accent_color`, `support_contact`, `colors`, `sync_modes`
- BetterDesk: `phone`, `email`, `website`, `logo` (`mime` + `data_base64` or `url`), `revision`
- `profiles.betterdesk.apply` — fields BetterDesk desktop applies
- `profiles.rustdesk.config_options` — subset safe for stock RustDesk via heartbeat
`POST /api/branding` — admin (JWT / `X-API-Key`); validates colors, logo size (≤ 512 KiB), MIME.
## Client behaviour
| Client | Path |
|--------|------|
| **BetterDesk desktop** | Polls `GET /api/branding` from the sync loop; writes LocalConfig; clears when `api-server` is removed/changed or `revision` is `0` |
| **Stock RustDesk** | Does **not** call `/api/branding`. On heartbeat, when `modified_at` (i64 ms) differs, may receive `strategy.config_options` (e.g. `display-name`). Unknown JSON fields are ignored |
## Heartbeat
`POST /api/heartbeat` returns `modified_at` as **Unix milliseconds (int64)** so RustDesk/BetterDesk strategy cursors advance. When the client cursor is stale and Client Branding has a RustDesk projection, the response includes:
```json
{
"modified_at": 1725620400123,
"strategy": { "config_options": { "display-name": "Acme" } }
}
```
## Related files
- Go: `betterdesk-server/api/branding_handlers.go`, `client_api_handlers.go`
- Panel: `web-nodejs/routes/client-branding.routes.js`, `views/client-branding.ejs`
- Desktop client: `BetterDesk-Client/src/hbbs_http/betterdesk.rs`, `sync.rs`
+25 -1
View File
@@ -86,7 +86,8 @@
"server_management": "إدارة الخادم",
"server_attestation": "اعتماد أداء الخادم",
"commercialization": "التسويق",
"remote_open_tab": "فتح عميل سطح المكتب البعيد في علامة تبويب جديدة"
"remote_open_tab": "فتح عميل سطح المكتب البعيد في علامة تبويب جديدة",
"client_branding": "Client Branding"
},
"auth": {
"login": "Login",
@@ -4322,5 +4323,28 @@
"link_sponsors_md": "قائمة الرعاة الكاملة",
"close": "إغلاق"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -79,7 +79,8 @@
"server_management": "Správa serveru",
"server_attestation": "Atestace serveru",
"commercialization": "Komerce",
"remote_open_tab": "Otevřít klienta vzdálené plochy v nové kartě"
"remote_open_tab": "Otevřít klienta vzdálené plochy v nové kartě",
"client_branding": "Client Branding"
},
"auth": {
"login": "Přihlášení",
@@ -4315,5 +4316,28 @@
"link_sponsors_md": "Úplný seznam sponzorů",
"close": "Zavřít"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -85,7 +85,8 @@
"server_management": "Serveradministration",
"server_attestation": "Serverattest",
"commercialization": "Kommercialisering",
"remote_open_tab": "Åbn fjernskrivebordsklient i ny fane"
"remote_open_tab": "Åbn fjernskrivebordsklient i ny fane",
"client_branding": "Client Branding"
},
"auth": {
"login": "Login",
@@ -4321,5 +4322,28 @@
"link_sponsors_md": "Fuld sponsorliste",
"close": "Luk"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -79,7 +79,8 @@
"server_management": "Server-Verwaltung",
"server_attestation": "Server-Attestierung",
"commercialization": "Kommerzialisierung",
"remote_open_tab": "Remote-Desktop-Client in neuem Tab öffnen"
"remote_open_tab": "Remote-Desktop-Client in neuem Tab öffnen",
"client_branding": "Client Branding"
},
"auth": {
"login": "Anmelden",
@@ -4315,5 +4316,28 @@
"link_sponsors_md": "Vollständige Sponsorenliste",
"close": "Schließen"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+24
View File
@@ -54,6 +54,7 @@
"reports": "Reports",
"tenants": "Tenants",
"registrations": "Enrollment Requests",
"client_branding": "Client Branding",
"management": "Management",
"toggle_sidebar": "Toggle sidebar",
"toggle_theme": "Toggle theme",
@@ -4313,5 +4314,28 @@
"link_sponsors_md": "Full sponsors list",
"close": "Close"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -79,7 +79,8 @@
"server_management": "Gestión del servidor",
"server_attestation": "Certificación del servidor",
"commercialization": "Comercialización",
"remote_open_tab": "Abrir cliente de escritorio remoto en una pestaña nueva"
"remote_open_tab": "Abrir cliente de escritorio remoto en una pestaña nueva",
"client_branding": "Client Branding"
},
"auth": {
"login": "Iniciar sesión",
@@ -4315,5 +4316,28 @@
"link_sponsors_md": "Lista completa de patrocinadores",
"close": "Cerrar"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -85,7 +85,8 @@
"server_management": "Palvelimen hallinta",
"server_attestation": "Palvelintodistus",
"commercialization": "Kaupallistaminen",
"remote_open_tab": "Avaa etätyöpöytäasiakas uuteen välilehteen"
"remote_open_tab": "Avaa etätyöpöytäasiakas uuteen välilehteen",
"client_branding": "Client Branding"
},
"auth": {
"login": "Login",
@@ -4321,5 +4322,28 @@
"link_sponsors_md": "Täydellinen sponsorilista",
"close": "Sulje"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -83,7 +83,8 @@
"server_management": "Server-Verwaltung",
"server_attestation": "Server-Attestierung",
"commercialization": "Kommerzialisierung",
"remote_open_tab": "Ouvrir le client Bureau à distance dans un nouvel onglet"
"remote_open_tab": "Ouvrir le client Bureau à distance dans un nouvel onglet",
"client_branding": "Client Branding"
},
"auth": {
"login": "Connexion",
@@ -4319,5 +4320,28 @@
"link_sponsors_md": "Liste complète des sponsors",
"close": "Fermer"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -85,7 +85,8 @@
"server_management": "सर्वर प्रबंधन",
"server_attestation": "सर्वर प्रमाणीकरण",
"commercialization": "व्यावसायीकरण",
"remote_open_tab": "नए टैब में रिमोट डेस्कटॉप क्लाइंट खोलें"
"remote_open_tab": "नए टैब में रिमोट डेस्कटॉप क्लाइंट खोलें",
"client_branding": "Client Branding"
},
"auth": {
"login": "Login",
@@ -4321,5 +4322,28 @@
"link_sponsors_md": "पूर्ण प्रायोजक सूची",
"close": "बंद करें"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -85,7 +85,8 @@
"server_management": "Kiszolgáló-kezelés",
"server_attestation": "Szerver tanúsítás",
"commercialization": "Kereskedelmi modul",
"remote_open_tab": "Távoli asztal kliens megnyitása új lapon"
"remote_open_tab": "Távoli asztal kliens megnyitása új lapon",
"client_branding": "Client Branding"
},
"auth": {
"login": "Login",
@@ -4321,5 +4322,28 @@
"link_sponsors_md": "Teljes szponzorlista",
"close": "Bezárás"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -85,7 +85,8 @@
"server_management": "Manajemen Server",
"server_attestation": "Sertifikasi Server",
"commercialization": "Komersialisasi",
"remote_open_tab": "Buka klien desktop jarak jauh di tab baru"
"remote_open_tab": "Buka klien desktop jarak jauh di tab baru",
"client_branding": "Client Branding"
},
"auth": {
"login": "Login",
@@ -4321,5 +4322,28 @@
"link_sponsors_md": "Daftar sponsor lengkap",
"close": "Tutup"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -79,7 +79,8 @@
"server_management": "Gestione server",
"server_attestation": "Attestazione server",
"commercialization": "Commercializzazione",
"remote_open_tab": "Apri client desktop remoto in una nuova scheda"
"remote_open_tab": "Apri client desktop remoto in una nuova scheda",
"client_branding": "Client Branding"
},
"auth": {
"login": "Accedi",
@@ -4315,5 +4316,28 @@
"link_sponsors_md": "Elenco completo dei sponsor",
"close": "Chiudi"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -79,7 +79,8 @@
"server_management": "サーバー管理",
"server_attestation": "サーバー性能認定",
"commercialization": "商用化",
"remote_open_tab": "新しいタブでリモートデスクトップクライアントを開く"
"remote_open_tab": "新しいタブでリモートデスクトップクライアントを開く",
"client_branding": "Client Branding"
},
"auth": {
"login": "Login",
@@ -4315,5 +4316,28 @@
"link_sponsors_md": "スポンサー一覧",
"close": "閉じる"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -79,7 +79,8 @@
"server_management": "서버 관리",
"server_attestation": "서버 성능 인증",
"commercialization": "상용화",
"remote_open_tab": "새 탭에서 원격 데스크톱 클라이언트 열기"
"remote_open_tab": "새 탭에서 원격 데스크톱 클라이언트 열기",
"client_branding": "Client Branding"
},
"auth": {
"login": "Login",
@@ -4315,5 +4316,28 @@
"link_sponsors_md": "전체 스폰서 목록",
"close": "닫기"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -85,7 +85,8 @@
"server_management": "Serveradministrasjon",
"server_attestation": "Serverattest",
"commercialization": "Kommersialisering",
"remote_open_tab": "Åpne eksternt skrivebordsklient i ny fane"
"remote_open_tab": "Åpne eksternt skrivebordsklient i ny fane",
"client_branding": "Client Branding"
},
"auth": {
"login": "Login",
@@ -4321,5 +4322,28 @@
"link_sponsors_md": "Full sponsorliste",
"close": "Lukk"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -79,7 +79,8 @@
"server_management": "Serverbeheer",
"server_attestation": "Serverattestering",
"commercialization": "Commercialisatie",
"remote_open_tab": "Externe-bureaubladclient openen in nieuw tabblad"
"remote_open_tab": "Externe-bureaubladclient openen in nieuw tabblad",
"client_branding": "Client Branding"
},
"auth": {
"login": "Inloggen",
@@ -4315,5 +4316,28 @@
"link_sponsors_md": "Volledige sponsorslijst",
"close": "Sluiten"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -79,7 +79,8 @@
"server_management": "Zarządzanie serwerem",
"server_attestation": "Atest serwera",
"commercialization": "Komercjalizacja",
"remote_open_tab": "Otwórz klienta pulpitu zdalnego w nowej karcie"
"remote_open_tab": "Otwórz klienta pulpitu zdalnego w nowej karcie",
"client_branding": "Branding klienta"
},
"auth": {
"login": "Zaloguj",
@@ -4315,5 +4316,28 @@
"link_sponsors_md": "Pełna lista sponsorów",
"close": "Zamknij"
}
},
"client_branding": {
"subtitle": "White-label dla klientów desktop BetterDesk i kompatybilny podzbiór dla RustDesk.",
"form_title": "Profil Client Branding",
"hint": "Zapis trafia na serwer BetterDesk (Go). Klienci BetterDesk pobierają pełny branding automatycznie; stock RustDesk dostaje tylko bezpieczny podzbiór przez heartbeat.",
"company_name": "Nazwa firmy",
"support_contact": "Kontakt wsparcia",
"phone": "Telefon",
"email": "E-mail",
"website": "Strona WWW",
"accent_color": "Kolor akcentu",
"logo": "Logo",
"logo_hint": "PNG, JPEG lub WebP, max 512 KiB.",
"clear_logo": "Usuń logo",
"preview_betterdesk": "Podgląd BetterDesk",
"preview_rustdesk": "Podzbiór RustDesk (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk nie woła /api/branding — te opcje idą w strategy.config_options.",
"revision": "Rewizja",
"saved": "Branding klienta zapisany",
"save_failed": "Nie udało się zapisać brandingu",
"load_failed": "Nie udało się wczytać brandingu",
"logo_too_large": "Logo może mieć co najwyżej 512 KiB",
"logo_invalid": "Nieprawidłowy format logo"
}
}
+25 -1
View File
@@ -79,7 +79,8 @@
"server_management": "Gestão do servidor",
"server_attestation": "Atestação do servidor",
"commercialization": "Comercialização",
"remote_open_tab": "Abrir cliente de ambiente de trabalho remoto num novo separador"
"remote_open_tab": "Abrir cliente de ambiente de trabalho remoto num novo separador",
"client_branding": "Client Branding"
},
"auth": {
"login": "Entrar",
@@ -4315,5 +4316,28 @@
"link_sponsors_md": "Lista completa de patrocinadores",
"close": "Fechar"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -85,7 +85,8 @@
"server_management": "Gestionare server",
"server_attestation": "Atestare server",
"commercialization": "Comercializare",
"remote_open_tab": "Deschide clientul desktop la distanță într-o filă nouă"
"remote_open_tab": "Deschide clientul desktop la distanță într-o filă nouă",
"client_branding": "Client Branding"
},
"auth": {
"login": "Login",
@@ -4321,5 +4322,28 @@
"link_sponsors_md": "Lista completă de sponsori",
"close": "Închide"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -85,7 +85,8 @@
"server_management": "Serverhantering",
"server_attestation": "Serverattest",
"commercialization": "Kommersialisering",
"remote_open_tab": "Öppna fjärrskrivbordsklient i ny flik"
"remote_open_tab": "Öppna fjärrskrivbordsklient i ny flik",
"client_branding": "Client Branding"
},
"auth": {
"login": "Login",
@@ -4321,5 +4322,28 @@
"link_sponsors_md": "Fullständig sponsorlista",
"close": "Stäng"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -85,7 +85,8 @@
"server_management": "จัดการเซิร์ฟเวอร์",
"server_attestation": "การรับรองประสิทธิภาพเซิร์ฟเวอร์",
"commercialization": "การทำให้เป็นเชิงพาณิชย์",
"remote_open_tab": "เปิดไคลเอนต์เดสก์ท็อประยะไกลในแท็บใหม่"
"remote_open_tab": "เปิดไคลเอนต์เดสก์ท็อประยะไกลในแท็บใหม่",
"client_branding": "Client Branding"
},
"auth": {
"login": "Login",
@@ -4321,5 +4322,28 @@
"link_sponsors_md": "รายชื่อสปอนเซอร์ทั้งหมด",
"close": "ปิด"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -85,7 +85,8 @@
"server_management": "Sunucu Yönetimi",
"server_attestation": "Sunucu Performans Onayı",
"commercialization": "Ticarileştirme",
"remote_open_tab": "Uzak masaüstü istemcisini yeni sekmede aç"
"remote_open_tab": "Uzak masaüstü istemcisini yeni sekmede aç",
"client_branding": "Client Branding"
},
"auth": {
"login": "Login",
@@ -4321,5 +4322,28 @@
"link_sponsors_md": "Tam sponsor listesi",
"close": "Kapat"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -85,7 +85,8 @@
"server_management": "Керування сервером",
"server_attestation": "Атестація сервера",
"commercialization": "Комерціалізація",
"remote_open_tab": "Відкрити клієнт віддаленого робочого столу в новій вкладці"
"remote_open_tab": "Відкрити клієнт віддаленого робочого столу в новій вкладці",
"client_branding": "Client Branding"
},
"auth": {
"login": "Login",
@@ -4321,5 +4322,28 @@
"link_sponsors_md": "Повний список спонсорів",
"close": "Закрити"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -85,7 +85,8 @@
"server_management": "Quản lý máy chủ",
"server_attestation": "Chứng nhận máy chủ",
"commercialization": "Thương mại hóa",
"remote_open_tab": "Mở client máy tính để bàn từ xa trong tab mới"
"remote_open_tab": "Mở client máy tính để bàn từ xa trong tab mới",
"client_branding": "Client Branding"
},
"auth": {
"login": "Login",
@@ -4321,5 +4322,28 @@
"link_sponsors_md": "Danh sách nhà tài trợ đầy đủ",
"close": "Đóng"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -83,7 +83,8 @@
"server_management": "服務器管理",
"server_attestation": "服務器性能認證",
"commercialization": "商業化",
"remote_open_tab": "在新分頁中開啟遠端桌面用戶端"
"remote_open_tab": "在新分頁中開啟遠端桌面用戶端",
"client_branding": "Client Branding"
},
"auth": {
"login": "登錄",
@@ -4319,5 +4320,28 @@
"link_sponsors_md": "完整贊助者清單",
"close": "關閉"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+25 -1
View File
@@ -79,7 +79,8 @@
"server_management": "服务器管理",
"server_attestation": "服务器性能认证",
"commercialization": "商业化",
"remote_open_tab": "在新标签页中打开远程桌面客户端"
"remote_open_tab": "在新标签页中打开远程桌面客户端",
"client_branding": "Client Branding"
},
"auth": {
"login": "登录",
@@ -4315,5 +4316,28 @@
"link_sponsors_md": "完整赞助者列表",
"close": "关闭"
}
},
"client_branding": {
"subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.",
"form_title": "Client Branding profile",
"hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.",
"company_name": "Company name",
"support_contact": "Support contact",
"phone": "Phone",
"email": "Email",
"website": "Website",
"accent_color": "Accent color",
"logo": "Logo",
"logo_hint": "PNG, JPEG, or WebP, max 512 KiB.",
"clear_logo": "Clear logo",
"preview_betterdesk": "BetterDesk preview",
"preview_rustdesk": "RustDesk subset (heartbeat)",
"preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.",
"revision": "Revision",
"saved": "Client branding saved",
"save_failed": "Failed to save branding",
"load_failed": "Failed to load branding",
"logo_too_large": "Logo must be at most 512 KiB",
"logo_invalid": "Invalid logo format"
}
}
+115
View File
@@ -0,0 +1,115 @@
/* Client Branding page */
.client-branding-layout {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr);
gap: 1.25rem;
align-items: start;
}
.client-branding-form .form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem 1.25rem;
}
.client-branding-form .form-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.client-branding-form .form-field-wide {
grid-column: 1 / -1;
}
.client-branding-form .form-field input[type="text"],
.client-branding-form .form-field input[type="email"],
.client-branding-form .form-field input[type="url"],
.client-branding-form .form-field input[type="file"] {
width: 100%;
}
.client-branding-form .color-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.client-branding-form .color-row input[type="color"] {
width: 2.5rem;
height: 2.5rem;
padding: 0;
border: none;
background: transparent;
}
.client-branding-form .field-hint,
.client-branding-form .settings-row-hint {
font-size: 0.85rem;
opacity: 0.75;
}
.client-branding-previews {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.cb-preview-pane {
border-radius: 8px;
padding: 1.25rem;
background: color-mix(in srgb, var(--cb-accent, #4f6ef7) 12%, var(--surface, #161b22));
border: 1px solid color-mix(in srgb, var(--cb-accent, #4f6ef7) 35%, transparent);
min-height: 160px;
}
.cb-preview-logo-wrap {
width: 64px;
height: 64px;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
margin-bottom: 0.75rem;
}
.cb-preview-logo-wrap img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.cb-preview-company {
font-size: 1.15rem;
font-weight: 600;
color: var(--cb-accent, #4f6ef7);
}
.cb-preview-contact {
margin-top: 0.5rem;
font-size: 0.9rem;
opacity: 0.85;
word-break: break-word;
}
.cb-preview-code {
margin: 0;
padding: 0.75rem;
overflow: auto;
font-size: 0.8rem;
border-radius: 6px;
background: rgba(0, 0, 0, 0.25);
}
@media (max-width: 960px) {
.client-branding-layout {
grid-template-columns: 1fr;
}
.client-branding-form .form-grid {
grid-template-columns: 1fr;
}
}
+232
View File
@@ -0,0 +1,232 @@
/* BetterDesk Console — Client Branding page */
(function () {
'use strict';
const t = (k, def) => {
const tr = window.t ? window.t(k) : k;
return (tr && tr !== k) ? tr : (def != null ? def : k);
};
const notify = window.Notifications || {
success: console.log,
error: console.error,
warning: console.warn,
info: console.info,
};
const csrf = () => (window.BetterDesk && window.BetterDesk.csrfToken) || '';
const MAX_LOGO = 512 * 1024;
const state = {
logo: null,
clearLogo: false,
revision: '',
};
async function api(method, url, body) {
const headers = { Accept: 'application/json' };
const write = method === 'POST' || method === 'PUT' || method === 'PATCH';
if (write) headers['Content-Type'] = 'application/json';
if (method !== 'GET' && method !== 'HEAD') headers['X-CSRF-Token'] = csrf();
const opts = { method, headers, credentials: 'same-origin' };
if (body !== undefined) opts.body = JSON.stringify(body);
else if (write) opts.body = '{}';
const res = await fetch(url, opts);
const ct = res.headers.get('content-type') || '';
const data = ct.includes('application/json') ? await res.json() : null;
if (!res.ok || (data && data.success === false)) {
throw new Error((data && data.error) || `HTTP ${res.status}`);
}
return data;
}
function $(id) {
return document.getElementById(id);
}
function normalizeColor(raw) {
const v = String(raw || '').trim();
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(v)) return v;
return '#4f6ef7';
}
function syncAccentInputs(fromPicker) {
const picker = $('cb-accent-picker');
const text = $('cb-accent');
if (!picker || !text) return;
if (fromPicker) {
text.value = picker.value;
} else {
const c = normalizeColor(text.value);
text.value = c;
picker.value = c.length === 4
? '#' + c[1] + c[1] + c[2] + c[2] + c[3] + c[3]
: c;
}
updatePreview();
}
function contactLines() {
const parts = [];
const phone = ($('cb-phone')?.value || '').trim();
const email = ($('cb-email')?.value || '').trim();
const website = ($('cb-website')?.value || '').trim();
const support = ($('cb-support')?.value || '').trim();
if (phone) parts.push(phone);
if (email) parts.push(email);
if (website) parts.push(website);
if (support) parts.push(support);
return parts;
}
function updatePreview() {
const company = ($('cb-company')?.value || '').trim() || 'BetterDesk';
const accent = normalizeColor($('cb-accent')?.value);
const companyEl = $('cb-preview-company');
const contactEl = $('cb-preview-contact');
const pane = $('cb-preview-bd');
if (companyEl) companyEl.textContent = company;
if (contactEl) contactEl.textContent = contactLines().join(' · ');
if (pane) pane.style.setProperty('--cb-accent', accent);
const logoImg = $('cb-preview-logo');
const fallback = $('cb-preview-logo-fallback');
const src = state.logo && state.logo.data_base64
? `data:${state.logo.mime};base64,${state.logo.data_base64}`
: (state.logo && state.logo.url ? state.logo.url : '');
if (logoImg && fallback) {
if (src && !state.clearLogo) {
logoImg.src = src;
logoImg.hidden = false;
fallback.hidden = true;
} else {
logoImg.removeAttribute('src');
logoImg.hidden = true;
fallback.hidden = false;
}
}
const rd = {};
if (company && company.toLowerCase() !== 'betterdesk') {
rd['display-name'] = company;
}
const rdEl = $('cb-preview-rd');
if (rdEl) {
rdEl.textContent = JSON.stringify({ config_options: rd }, null, 2);
}
const rev = $('cb-revision');
if (rev) {
rev.textContent = state.revision
? `${t('client_branding.revision', 'Revision')}: ${state.revision}`
: '';
}
}
function fillForm(data) {
data = data || {};
$('cb-company').value = data.company_name || '';
$('cb-phone').value = data.phone || '';
$('cb-email').value = data.email || '';
$('cb-website').value = data.website || '';
$('cb-support').value = data.support_contact || '';
$('cb-accent').value = data.accent_color || '#4f6ef7';
state.revision = data.revision || '';
state.clearLogo = false;
state.logo = data.logo || null;
syncAccentInputs(false);
updatePreview();
}
function readFileAsLogo(file) {
return new Promise((resolve, reject) => {
if (!file) return resolve(null);
if (file.size <= 0 || file.size > MAX_LOGO) {
return reject(new Error(t('client_branding.logo_too_large', 'Logo must be at most 512 KiB')));
}
const mime = (file.type || '').toLowerCase();
if (!['image/png', 'image/jpeg', 'image/jpg', 'image/webp'].includes(mime)) {
return reject(new Error(t('client_branding.logo_invalid', 'Invalid logo format')));
}
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result || '');
const comma = result.indexOf(',');
const dataBase64 = comma >= 0 ? result.slice(comma + 1) : result;
resolve({ mime: mime === 'image/jpg' ? 'image/jpeg' : mime, data_base64: dataBase64 });
};
reader.onerror = () => reject(new Error(t('client_branding.logo_invalid', 'Invalid logo format')));
reader.readAsDataURL(file);
});
}
async function load() {
const result = await api('GET', '/api/client-branding');
fillForm(result.data || {});
}
async function save() {
const payload = {
company_name: ($('cb-company')?.value || '').trim(),
phone: ($('cb-phone')?.value || '').trim(),
email: ($('cb-email')?.value || '').trim(),
website: ($('cb-website')?.value || '').trim(),
support_contact: ($('cb-support')?.value || '').trim(),
accent_color: normalizeColor($('cb-accent')?.value),
};
if (state.clearLogo) payload.clear_logo = true;
else if (state.logo && (state.logo.data_base64 || state.logo.url)) {
payload.logo = state.logo;
}
const result = await api('POST', '/api/client-branding', payload);
const branding = (result.data && result.data.branding) || result.data || {};
if (result.revision) branding.revision = result.revision;
if (result.data && result.data.revision) branding.revision = result.data.revision;
fillForm(branding);
notify.success(t('client_branding.saved', 'Client branding saved'));
}
function bind() {
['cb-company', 'cb-phone', 'cb-email', 'cb-website', 'cb-support'].forEach((id) => {
$(id)?.addEventListener('input', updatePreview);
});
$('cb-accent')?.addEventListener('input', () => syncAccentInputs(false));
$('cb-accent-picker')?.addEventListener('input', () => syncAccentInputs(true));
$('cb-logo-file')?.addEventListener('change', async (ev) => {
const file = ev.target.files && ev.target.files[0];
try {
const logo = await readFileAsLogo(file);
if (logo) {
state.logo = logo;
state.clearLogo = false;
updatePreview();
}
} catch (err) {
notify.error(err.message);
ev.target.value = '';
}
});
$('cb-clear-logo')?.addEventListener('click', () => {
state.logo = null;
state.clearLogo = true;
const input = $('cb-logo-file');
if (input) input.value = '';
updatePreview();
});
$('cb-save')?.addEventListener('click', async () => {
try {
await save();
} catch (err) {
notify.error(err.message || t('client_branding.save_failed', 'Save failed'));
}
});
}
document.addEventListener('DOMContentLoaded', async () => {
bind();
try {
await load();
} catch (err) {
notify.error(err.message || t('client_branding.load_failed', 'Failed to load branding'));
updatePreview();
}
});
})();
@@ -0,0 +1,92 @@
/**
* BetterDesk Console Client Branding (desktop clients)
*
* Main Client Branding edits the Go-server source of truth used by
* BetterDesk desktop (GET /api/branding) and stock RustDesk (heartbeat subset).
* Distinct from Settings Branding (console appearance / brandingService).
*/
'use strict';
const express = require('express');
const router = express.Router();
const { requireAuth, requirePermission } = require('../middleware/auth');
const betterdeskApi = require('../services/betterdeskApi');
const largeJson = express.json({ limit: '2mb' });
router.get(
'/client-branding',
requireAuth,
requirePermission('branding.edit'),
(req, res) => {
res.render('client-branding', {
title: req.t('nav.client_branding'),
});
}
);
router.get(
'/api/client-branding',
requireAuth,
requirePermission('branding.edit'),
async (req, res) => {
try {
const result = await betterdeskApi.getBranding();
if (!result.success) {
return res.status(502).json({
success: false,
error: result.error || 'Failed to load branding from BetterDesk server',
});
}
return res.json({ success: true, data: result.data });
} catch (err) {
console.error('[client-branding] GET failed:', err.message);
return res.status(500).json({ success: false, error: err.message });
}
}
);
router.post(
'/api/client-branding',
requireAuth,
requirePermission('branding.edit'),
largeJson,
async (req, res) => {
try {
const body = req.body || {};
const payload = {
company_name: body.company_name,
phone: body.phone,
email: body.email,
website: body.website,
accent_color: body.accent_color,
support_contact: body.support_contact,
colors: body.colors,
};
if (body.clear_logo) {
payload.clear_logo = true;
} else if (body.logo) {
payload.logo = body.logo;
}
const result = await betterdeskApi.saveBranding(payload);
if (!result.success) {
return res.status(502).json({
success: false,
error: result.error || 'Failed to save branding on BetterDesk server',
});
}
return res.json({
success: true,
data: result.data,
revision: result.data?.revision || result.revision,
});
} catch (err) {
console.error('[client-branding] POST failed:', err.message);
return res.status(500).json({ success: false, error: err.message });
}
}
);
module.exports = router;
+1
View File
@@ -105,6 +105,7 @@ router.use('/', devicesRoutes);
router.use('/', keysRoutes);
router.use('/', settingsRoutes);
router.use('/', generatorRoutes);
router.use('/', require('./client-branding.routes'));
router.use('/', usersRoutes);
router.use('/', foldersRoutes);
router.use('/', remoteRoutes);
+104
View File
@@ -0,0 +1,104 @@
<%- include('layouts/main', {
title: _('nav.client_branding'),
pageStyles: ['pages', 'client-branding'],
pageScripts: ['client-branding'],
currentPage: 'client-branding',
breadcrumb: [{ label: _('nav.client_branding') }],
body: `
<div class="page-header">
<h1 class="page-title">
<span class="material-icons">branding_watermark</span>
${_('nav.client_branding')}
</h1>
<p class="page-subtitle">${_('client_branding.subtitle')}</p>
</div>
<div class="client-branding-layout">
<section class="card client-branding-form-card">
<div class="card-header">
<h2 class="card-title">
<span class="material-icons">edit</span>
${_('client_branding.form_title')}
</h2>
<div class="editor-actions">
<span id="cb-revision" class="text-muted text-sm"></span>
<button type="button" id="cb-clear-logo" class="btn btn-secondary btn-sm">
<span class="material-icons">hide_image</span>
${_('client_branding.clear_logo')}
</button>
<button type="button" id="cb-save" class="btn btn-primary btn-sm">
<span class="material-icons">save</span>
${_('common.save')}
</button>
</div>
</div>
<div class="card-body">
<p class="settings-row-hint">${_('client_branding.hint')}</p>
<form id="cb-form" class="client-branding-form" autocomplete="off">
<div class="form-grid">
<label class="form-field">
<span>${_('client_branding.company_name')}</span>
<input type="text" id="cb-company" maxlength="256" />
</label>
<label class="form-field">
<span>${_('client_branding.support_contact')}</span>
<input type="text" id="cb-support" maxlength="256" />
</label>
<label class="form-field">
<span>${_('client_branding.phone')}</span>
<input type="text" id="cb-phone" maxlength="256" />
</label>
<label class="form-field">
<span>${_('client_branding.email')}</span>
<input type="email" id="cb-email" maxlength="256" />
</label>
<label class="form-field form-field-wide">
<span>${_('client_branding.website')}</span>
<input type="url" id="cb-website" maxlength="512" placeholder="https://" />
</label>
<label class="form-field">
<span>${_('client_branding.accent_color')}</span>
<div class="color-row">
<input type="color" id="cb-accent-picker" value="#4f6ef7" />
<input type="text" id="cb-accent" maxlength="7" pattern="^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$" />
</div>
</label>
<label class="form-field form-field-wide">
<span>${_('client_branding.logo')}</span>
<input type="file" id="cb-logo-file" accept="image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp" />
<span class="field-hint">${_('client_branding.logo_hint')}</span>
</label>
</div>
</form>
</div>
</section>
<aside class="client-branding-previews">
<section class="card">
<div class="card-header">
<h2 class="card-title">${_('client_branding.preview_betterdesk')}</h2>
</div>
<div class="card-body">
<div id="cb-preview-bd" class="cb-preview-pane cb-preview-bd">
<div class="cb-preview-logo-wrap">
<img id="cb-preview-logo" alt="" hidden />
<span id="cb-preview-logo-fallback" class="material-icons">business</span>
</div>
<div id="cb-preview-company" class="cb-preview-company">BetterDesk</div>
<div id="cb-preview-contact" class="cb-preview-contact"></div>
</div>
</div>
</section>
<section class="card">
<div class="card-header">
<h2 class="card-title">${_('client_branding.preview_rustdesk')}</h2>
</div>
<div class="card-body">
<p class="settings-row-hint">${_('client_branding.preview_rustdesk_hint')}</p>
<pre id="cb-preview-rd" class="cb-preview-code">{}</pre>
</div>
</section>
</aside>
</div>
`
}) %>
+7 -1
View File
@@ -21,7 +21,7 @@
<!-- Category icons -->
<nav class="sidebar-rail-nav">
<button class="sidebar-rail-btn <%= ['dashboard','devices','registrations'].includes(currentPage) ? 'active' : '' %>"
<button class="sidebar-rail-btn <%= ['dashboard','devices','registrations','client-branding'].includes(currentPage) ? 'active' : '' %>"
data-category="main" title="<%= _('nav.main') %>">
<span class="material-icons">home</span>
</button>
@@ -126,6 +126,12 @@
<span class="badge-sidebar" id="reg-sidebar-badge" style="display: none;">0</span>
</a>
<% } %>
<% if (hasPermission('branding.edit')) { %>
<a href="/client-branding" class="sidebar-link <%= currentPage === 'client-branding' ? 'active' : '' %>">
<span class="material-icons">branding_watermark</span>
<span class="sidebar-link-text"><%= _('nav.client_branding') %></span>
</a>
<% } %>
</div>
<!-- Management category links -->
@@ -27,6 +27,12 @@
<span class="ux35-sidebar-item-badge" id="reg-sidebar-badge" style="display: none;">0</span>
</a>
<% } %>
<% if (hasPermission('branding.edit')) { %>
<a href="/client-branding" class="ux35-sidebar-item <%= currentPage === 'client-branding' ? 'active' : '' %>">
<span class="material-icons">branding_watermark</span>
<span><%= _('nav.client_branding') %></span>
</a>
<% } %>
</div>
<!-- Management -->