mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
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:
@@ -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 ||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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": "..." ... }
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
}) %>
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user