feat(rdclient): enhance desktop client with server validation, LAN discovery, and settings management

- Introduced server URL validation via `GET /api/bd/server-info` and `probe_server_url`.
- Added LAN discovery capabilities using UDP and optional mDNS for local network panel detection.
- Implemented a local settings window for managing URL, TLS settings, and user preferences.
- Enhanced dashboard with a unified sidebar and improved scrolling behavior.
- Updated documentation and pre-release checklist to reflect new features and requirements.
This commit is contained in:
UNITRONIX
2026-06-14 21:12:13 +02:00
parent 54c8f797a8
commit 2e01955f6c
74 changed files with 3308 additions and 242 deletions
+13 -2
View File
@@ -1,7 +1,17 @@
## [Unreleased] ## [Unreleased]
### Added
- **RdClient desktop (production hardening):** server URL validation via `GET /api/bd/server-info` and `probe_server_url`; extended `config.json` (`tls_strict`, `ui_lang`, `discovered_via`); `BETTERDESK_SERVER_URL` env and embedded `betterdesk-rdclient.json` on first launch; LAN discovery (UDP port 21119 + optional mDNS `_betterdesk._tcp`); local **Settings** window (change URL, TLS, sign out, factory reset); peer **Remember device password** vault (IndexedDB AES-GCM per device); language switcher on `/remote/login`, dashboard, and viewer (26 locales); **Generator → RdClient desktop** bundles with `rdclientBuildWorker` (6 platform variants, embedded panel URL).
### Changed ### Changed
- _(none yet)_
- **RdClient codecs (Linux):** WebKitGTK skips unreliable AV1 negotiation; runtime fallback to VP9/H.264 on decode failure.
- **RdClient dashboard:** unified sidebar + device panel scrolling; desktop settings button in header.
### Manual steps (build host)
- RdClient installer builds require **Rust stable**, **npm**, **@tauri-apps/cli**, and Linux deps (`webkit2gtk4.1-devel`, `openssl-devel`, …) on the panel build host. Optional: `npm install bonjour-service` in `web-nodejs` for mDNS panel publish (`PANEL_MDNS=off` to disable).
--- ---
@@ -16,7 +26,8 @@
### Fixed ### Fixed
- **RdClient dashboard (web):** address book list/grid scrolls inside the workspace instead of stretching the whole page (fixed-height flex layout); collapsible device list panel and sidebar sections (Folders/Groups/Tags) with internal scroll; desktop shell applies stricter viewport height. - **RdClient dashboard (web):** address book and sidebar scroll inside fixed columns with hidden scrollbars; `.rd-desk-content` flex chain fixed so the device grid no longer stretches the window.
- **RdClient video (AV1):** WebKit uses software AV1 decode; agent `codec_string` is passed to `VideoDecoder`; AV1 keyframes build `description` (av1C); failed codecs auto-reconnect with VP9/H.264.
- **RdClient desktop:** **Connect** from the loaded `/remote` dashboard — runtime ACL for the configured panel origin (fixes LAN IP:port invoke), injected Connect bridge in the desktop shell (works before panel JS update), correct Tauri invoke args (`deviceId` / `deviceName`), and a new app window loading the same `/remote/:id` viewer as the web panel; session window **Back** closes the desktop window (injected handler + no `/devices` fallback). - **RdClient desktop:** **Connect** from the loaded `/remote` dashboard — runtime ACL for the configured panel origin (fixes LAN IP:port invoke), injected Connect bridge in the desktop shell (works before panel JS update), correct Tauri invoke args (`deviceId` / `deviceName`), and a new app window loading the same `/remote/:id` viewer as the web panel; session window **Back** closes the desktop window (injected handler + no `/devices` fallback).
- **RdClient desktop (performance):** Linux Wayland keeps WebKit DMA-BUF/GPU compositing enabled by default; vendor-specific VA-API (Intel `iHD`, AMD `radeonsi`, NVIDIA when `nvidia-vaapi-driver` is present); GStreamer hardware decoder rank for AV1/H264/VP9/H265; session windows disable background throttling; Windows WebView2 AV1/HEVC GPU decode flags; WebCodecs probes multiple AV1/H264 profiles for WebKitGTK compatibility. - **RdClient desktop (performance):** Linux Wayland keeps WebKit DMA-BUF/GPU compositing enabled by default; vendor-specific VA-API (Intel `iHD`, AMD `radeonsi`, NVIDIA when `nvidia-vaapi-driver` is present); GStreamer hardware decoder rank for AV1/H264/VP9/H265; session windows disable background throttling; Windows WebView2 AV1/HEVC GPU decode flags; WebCodecs probes multiple AV1/H264 profiles for WebKitGTK compatibility.
+17 -6
View File
@@ -24,7 +24,18 @@ Use this checklist before every tagged release to ensure quality and stability.
- [ ] **Login**: Admin login works, session created - [ ] **Login**: Admin login works, session created
- [ ] **Critical pages**: Dashboard, Devices, Users, Settings render correctly - [ ] **Critical pages**: Dashboard, Devices, Users, Settings render correctly
## 3. Desktop Client (Tauri) ## 3. RdClient Desktop (Tauri)
- [ ] **Install deps**: `cd rdclient-desktop && npm ci`
- [ ] **Rust check**: `cd rdclient-desktop/src-tauri && cargo check && cargo test discovery`
- [ ] **Tauri build**: `npm run build` — deb/AppImage/MSI as configured
- [ ] **Setup**: LAN discovery or manual URL; `probe_server_url` rejects non-panel hosts
- [ ] **Settings**: sign out, reset client clears cookies + vault
- [ ] **Linux session**: VP9/H.264 stable on WebKitGTK (no AV1 decode loop)
- [ ] **Windows**: WebView2 runtime present; session connects
- [ ] **Generator RdClient**: new bundle → 6 platform builds queue on build host with Rust/Tauri toolchain
## 4. Desktop Client (Tauri — legacy betterdesk-mgmt)
- [ ] **Install deps**: `cd betterdesk-mgmt && pnpm install` - [ ] **Install deps**: `cd betterdesk-mgmt && pnpm install`
- [ ] **Frontend build**: `pnpm build` — no errors - [ ] **Frontend build**: `pnpm build` — no errors
@@ -32,20 +43,20 @@ Use this checklist before every tagged release to ensure quality and stability.
- [ ] **Installer runs**: installs and launches without crash - [ ] **Installer runs**: installs and launches without crash
- [ ] **Single-instance**: second launch brings first to foreground - [ ] **Single-instance**: second launch brings first to foreground
## 4. Agent Client (Tauri) ## 5. Agent Client (Tauri)
- [ ] **Install deps**: `cd betterdesk-agent-client && pnpm install` - [ ] **Install deps**: `cd betterdesk-agent-client && pnpm install`
- [ ] **Build**: `cargo tauri build` — NSIS produced - [ ] **Build**: `cargo tauri build` — NSIS produced
- [ ] **Setup wizard**: 5-step onboarding completes successfully - [ ] **Setup wizard**: 5-step onboarding completes successfully
- [ ] **Registration**: device appears in server peer list - [ ] **Registration**: device appears in server peer list
## 5. Native Agent (Go) ## 6. Native Agent (Go)
- [ ] **Build**: `cd betterdesk-agent && go build -o betterdesk-agent .` - [ ] **Build**: `cd betterdesk-agent && go build -o betterdesk-agent .`
- [ ] **Connection**: connects to CDAP gateway, manifests registers - [ ] **Connection**: connects to CDAP gateway, manifests registers
- [ ] **Heartbeat**: metrics flow (CPU / Memory / Disk) - [ ] **Heartbeat**: metrics flow (CPU / Memory / Disk)
## 6. Docker ## 7. Docker
- [ ] **Build all images**: `docker compose build` — no errors - [ ] **Build all images**: `docker compose build` — no errors
- [ ] **Start stack**: `docker compose up -d` — all containers healthy - [ ] **Start stack**: `docker compose up -d` — all containers healthy
@@ -53,7 +64,7 @@ Use this checklist before every tagged release to ensure quality and stability.
- [ ] **Console reachable**: `curl http://localhost:5000` returns HTML - [ ] **Console reachable**: `curl http://localhost:5000` returns HTML
- [ ] **Single-container**: `docker compose -f docker-compose.single.yml up -d` works - [ ] **Single-container**: `docker compose -f docker-compose.single.yml up -d` works
## 7. Installer Scripts ## 8. Installer Scripts
- [ ] **Linux fresh**: `sudo ./betterdesk.sh --auto` on clean Ubuntu/Debian - [ ] **Linux fresh**: `sudo ./betterdesk.sh --auto` on clean Ubuntu/Debian
- [ ] **Linux update**: `sudo ./betterdesk.sh` option 2 preserves DB + config - [ ] **Linux update**: `sudo ./betterdesk.sh` option 2 preserves DB + config
@@ -61,7 +72,7 @@ Use this checklist before every tagged release to ensure quality and stability.
- [ ] **Windows update**: `.\betterdesk.ps1` option 2 preserves DB + config - [ ] **Windows update**: `.\betterdesk.ps1` option 2 preserves DB + config
- [ ] **Docker script**: `./betterdesk-docker.sh` option 1 installs successfully - [ ] **Docker script**: `./betterdesk-docker.sh` option 1 installs successfully
## 8. Documentation & Release ## 9. Documentation & Release
Applies only to merges into **`main`** (stable). Version bump, tag, and GitHub Release are automated by [`.github/workflows/version-bump-main.yml`](../../.github/workflows/version-bump-main.yml) — see [branching-and-versioning.md](important/branching-and-versioning.md). Applies only to merges into **`main`** (stable). Version bump, tag, and GitHub Release are automated by [`.github/workflows/version-bump-main.yml`](../../.github/workflows/version-bump-main.yml) — see [branching-and-versioning.md](important/branching-and-versioning.md).
+38 -8
View File
@@ -2,7 +2,7 @@
Tauri v2 desktop shell for the RdClient operator UI. The app loads your panels **`/remote`** dashboard in the main window and opens each remote session in a **separate window** (`/remote/:deviceId`), similar to RustDesk. Tauri v2 desktop shell for the RdClient operator UI. The app loads your panels **`/remote`** dashboard in the main window and opens each remote session in a **separate window** (`/remote/:deviceId`), similar to RustDesk.
This is **Phase C (MVP)** of the RdClient roadmap. It reuses the web dashboard and login flow; JWT/keychain auth is planned for a later phase. This is **production-ready Phase C+** of the RdClient roadmap: server validation, LAN discovery, settings/reset, encrypted peer passwords, full panel i18n, codec fallbacks on Linux, and generator-built installers with embedded panel URL.
## Prerequisites ## Prerequisites
@@ -111,9 +111,28 @@ Production bundle instead of dev:
**Important:** the dashboard HTML/JS is loaded from your **panel URL** (`/remote`). Updating only the desktop binary is not enough for UI tweaks — run **Settings → Updates** on the panel (or deploy `web-nodejs`) so `/js/remote-dashboard.js` is current. The desktop shell also injects a Connect bridge, so **Connect works even before the panel JS update** once you run a freshly built binary. **Important:** the dashboard HTML/JS is loaded from your **panel URL** (`/remote`). Updating only the desktop binary is not enough for UI tweaks — run **Settings → Updates** on the panel (or deploy `web-nodejs`) so `/js/remote-dashboard.js` is current. The desktop shell also injects a Connect bridge, so **Connect works even before the panel JS update** once you run a freshly built binary.
1. On first launch, enter your panel base URL (e.g. `https://desk.example.com`). 1. On first launch, enter your panel base URL (e.g. `https://desk.example.com`) or pick a server from **LAN discovery**.
2. Sign in at **`/remote/login`** when prompted (same as the web RdClient). 2. Sign in at **`/remote/login`** when prompted (same as the web RdClient).
3. Use **Connect** on a device — the desktop opens a new window instead of a browser tab. 3. Use **Connect** on a device — the desktop opens a new window instead of a browser tab.
4. Open **Settings** (gear icon in the dashboard header) to change URL, TLS mode, language, sign out, or **Reset client** (clears config, cookies, and saved passwords).
### Environment & embedded URL
| Source | Purpose |
|--------|---------|
| `BETTERDESK_SERVER_URL` | Auto-configure panel URL before setup UI |
| `betterdesk-rdclient.json` next to the binary | Installer-embedded `{ "server_url": "https://…" }` from Generator |
| UDP / mDNS LAN discovery | Setup UI lists panels on the local network |
### Password storage
- **Operator login (“Remember me”):** `RdClientSecureStore` in IndexedDB (AES-GCM).
- **Device password (“Remember device password”):** same vault, per `deviceId` (`peer:{id}` keys). Passwords never leave the device.
- **Reset client:** clears `config.json`, WebView cookies/storage, and the vault.
### LAN discovery
The panel publishes itself via UDP (port **21119**, always on) and optionally mDNS `_betterdesk._tcp` when `bonjour-service` is installed and `PANEL_MDNS` is not `off`.
## Build ## Build
@@ -127,9 +146,12 @@ Installers/binaries are under `src-tauri/target/release/bundle/`.
| Piece | Role | | Piece | Role |
|-------|------| |-------|------|
| `src/setup.html` | First-run local page to save the panel URL | | `src/setup.html` | First-run: LAN discovery list + manual panel URL |
| `src-tauri/src/lib.rs` | Tauri commands: `get_server_url`, `set_server_url`, `open_session` | | `src/settings.html` | Local settings: URL, TLS, language, sign out, reset |
| `src-tauri/src/config.rs` | Persists `server_url` in the app config dir | | `src-tauri/src/lib.rs` | Tauri commands: probe, discover, settings, sign out, reset, sessions |
| `src-tauri/src/config.rs` | Persists extended config + embedded/env URL helpers |
| `src-tauri/src/server_probe.rs` | Validates panel via `/api/bd/server-info` |
| `src-tauri/src/discovery.rs` | UDP LAN browse (BetterDesk announce protocol) |
| `src-tauri/src/linux_display.rs` | Linux X11/Wayland session + WebKitGTK workarounds | | `src-tauri/src/linux_display.rs` | Linux X11/Wayland session + WebKitGTK workarounds |
| `src-tauri/src/tls_policy.rs` | Windows WebView2 + strict-mode env | | `src-tauri/src/tls_policy.rs` | Windows WebView2 + strict-mode env |
| `scripts/rdclient-launcher.sh` | Optional wrapper for release binaries | | `scripts/rdclient-launcher.sh` | Optional wrapper for release binaries |
@@ -146,12 +168,20 @@ Config file: **`config.json`** in the OS app config directory (`com.betterdesk.r
cargo update -p alloc-no-stdlib@2.0.4 --precise 3.0.0 cargo update -p alloc-no-stdlib@2.0.4 --precise 3.0.0
``` ```
## Roadmap (not in this MVP) ## Verify matrix (manual)
| Platform | Check |
|----------|--------|
| **Linux Wayland** | Setup discovery, login, VP9/H.264 session (no AV1 loop), settings reset |
| **Linux X11** | Same as Wayland; test `BETTERDESK_UI_BACKEND=x11` if needed |
| **Windows x64** | WebView2 present, Connect, remember passwords, MSI/portable from Generator |
| **Fedora deb/rpm** | Installed bundle from Generator when build host has toolchain |
## Roadmap (not in this release)
- Operator JWT + OS keychain login (`POST /api/bd/operator/login`) - Operator JWT + OS keychain login (`POST /api/bd/operator/login`)
- WebSocket relay Bearer auth for long-lived desktop sessions - WebSocket relay Bearer auth for long-lived desktop sessions
- Settings → change server URL / sign out - Native Rust video decoder (if WebCodecs insufficient on Linux)
- CI release artifacts (replacing legacy `betterdesk-mgmt`)
## License ## License
+331 -1
View File
@@ -307,6 +307,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.45" version = "0.4.45"
@@ -1017,8 +1023,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1028,9 +1036,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 5.3.0", "r-efi 5.3.0",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1302,6 +1312,22 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.20"
@@ -1733,6 +1759,12 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.38.0" version = "0.38.0"
@@ -2282,6 +2314,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "precomputed-hash" name = "precomputed-hash"
version = "0.1.1" version = "0.1.1"
@@ -2369,6 +2410,61 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -2390,6 +2486,35 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@@ -2400,12 +2525,15 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
name = "rdclient-desktop" name = "rdclient-desktop"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"sys-locale",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-shell", "tauri-plugin-shell",
"tauri-utils", "tauri-utils",
"tokio",
] ]
[[package]] [[package]]
@@ -2477,6 +2605,44 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.4" version = "0.13.4"
@@ -2511,6 +2677,20 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@@ -2526,12 +2706,53 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustls"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@@ -2722,6 +2943,18 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.21.0" version = "3.21.0"
@@ -2962,6 +3195,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@@ -3014,6 +3253,15 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -3114,7 +3362,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest", "reqwest 0.13.4",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@@ -3450,9 +3698,31 @@ dependencies = [
"mio", "mio",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"
@@ -3749,6 +4019,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -3985,6 +4261,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "web_atoms" name = "web_atoms"
version = "0.2.4" version = "0.2.4"
@@ -4041,6 +4327,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-roots"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@@ -4271,6 +4566,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -4722,6 +5026,26 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zerocopy"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.8" version = "0.1.8"
@@ -4743,6 +5067,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.4" version = "0.2.4"
+3
View File
@@ -18,6 +18,9 @@ tauri-plugin-shell = "2"
tauri-utils = "2" tauri-utils = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
sys-locale = "0.3"
# brotli 8.0.3 on crates.io pulls conflicting alloc-no-stdlib 2.x/3.x; git brotli + unified allocator crate. # brotli 8.0.3 on crates.io pulls conflicting alloc-no-stdlib 2.x/3.x; git brotli + unified allocator crate.
# wry: accept operator-panel TLS (self-signed / incomplete chain) on Linux WebKitGTK. # wry: accept operator-panel TLS (self-signed / incomplete chain) on Linux WebKitGTK.
+9
View File
@@ -4,7 +4,16 @@ fn main() {
tauri_build::AppManifest::new().commands(&[ tauri_build::AppManifest::new().commands(&[
"get_client_info", "get_client_info",
"get_server_url", "get_server_url",
"get_config",
"probe_server_url",
"discover_servers",
"set_server_url", "set_server_url",
"set_tls_strict",
"set_ui_lang",
"get_system_locale",
"open_settings",
"sign_out",
"reset_client",
"open_session", "open_session",
"close_current_window", "close_current_window",
]), ]),
@@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2/capability", "$schema": "https://schema.tauri.app/config/2/capability",
"identifier": "default", "identifier": "default",
"description": "Default RdClient desktop capabilities", "description": "Default RdClient desktop capabilities",
"windows": ["main", "session-*"], "windows": ["main", "settings", "session-*"],
"remote": { "remote": {
"urls": [ "urls": [
"http://*", "http://*",
@@ -33,9 +33,19 @@
"core:window:allow-set-focus", "core:window:allow-set-focus",
"core:window:allow-close", "core:window:allow-close",
"core:webview:allow-create-webview-window", "core:webview:allow-create-webview-window",
"core:webview:allow-clear-all-browsing-data",
"shell:allow-open", "shell:allow-open",
"allow-get-server-url", "allow-get-server-url",
"allow-set-server-url", "allow-set-server-url",
"allow-probe-server-url",
"allow-discover-servers",
"allow-get-config",
"allow-set-tls-strict",
"allow-set-ui-lang",
"allow-get-system-locale",
"allow-open-settings",
"allow-sign-out",
"allow-reset-client",
"allow-open-session", "allow-open-session",
"allow-close-current-window", "allow-close-current-window",
"allow-get-client-info" "allow-get-client-info"
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-discover-servers"
description = "Enables the discover_servers command without any pre-configured scope."
commands.allow = ["discover_servers"]
[[permission]]
identifier = "deny-discover-servers"
description = "Denies the discover_servers command without any pre-configured scope."
commands.deny = ["discover_servers"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-get-config"
description = "Enables the get_config command without any pre-configured scope."
commands.allow = ["get_config"]
[[permission]]
identifier = "deny-get-config"
description = "Denies the get_config command without any pre-configured scope."
commands.deny = ["get_config"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-get-system-locale"
description = "Enables the get_system_locale command without any pre-configured scope."
commands.allow = ["get_system_locale"]
[[permission]]
identifier = "deny-get-system-locale"
description = "Denies the get_system_locale command without any pre-configured scope."
commands.deny = ["get_system_locale"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-open-settings"
description = "Enables the open_settings command without any pre-configured scope."
commands.allow = ["open_settings"]
[[permission]]
identifier = "deny-open-settings"
description = "Denies the open_settings command without any pre-configured scope."
commands.deny = ["open_settings"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-probe-server-url"
description = "Enables the probe_server_url command without any pre-configured scope."
commands.allow = ["probe_server_url"]
[[permission]]
identifier = "deny-probe-server-url"
description = "Denies the probe_server_url command without any pre-configured scope."
commands.deny = ["probe_server_url"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-reset-client"
description = "Enables the reset_client command without any pre-configured scope."
commands.allow = ["reset_client"]
[[permission]]
identifier = "deny-reset-client"
description = "Denies the reset_client command without any pre-configured scope."
commands.deny = ["reset_client"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-set-tls-strict"
description = "Enables the set_tls_strict command without any pre-configured scope."
commands.allow = ["set_tls_strict"]
[[permission]]
identifier = "deny-set-tls-strict"
description = "Denies the set_tls_strict command without any pre-configured scope."
commands.deny = ["set_tls_strict"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-set-ui-lang"
description = "Enables the set_ui_lang command without any pre-configured scope."
commands.allow = ["set_ui_lang"]
[[permission]]
identifier = "deny-set-ui-lang"
description = "Denies the set_ui_lang command without any pre-configured scope."
commands.deny = ["set_ui_lang"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-sign-out"
description = "Enables the sign_out command without any pre-configured scope."
commands.allow = ["sign_out"]
[[permission]]
identifier = "deny-sign-out"
description = "Denies the sign_out command without any pre-configured scope."
commands.deny = ["sign_out"]
+74 -3
View File
@@ -4,11 +4,37 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
pub const CONFIG_VERSION: u32 = 1;
const CONFIG_FILE: &str = "config.json"; const CONFIG_FILE: &str = "config.json";
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig { pub struct AppConfig {
#[serde(default = "default_config_version")]
pub config_version: u32,
#[serde(default)]
pub server_url: Option<String>, pub server_url: Option<String>,
#[serde(default)]
pub tls_strict: bool,
#[serde(default)]
pub discovered_via: Option<String>,
#[serde(default)]
pub ui_lang: Option<String>,
}
fn default_config_version() -> u32 {
CONFIG_VERSION
}
impl Default for AppConfig {
fn default() -> Self {
Self {
config_version: CONFIG_VERSION,
server_url: None,
tls_strict: false,
discovered_via: None,
ui_lang: None,
}
}
} }
pub fn config_path(app: &AppHandle) -> Result<PathBuf, String> { pub fn config_path(app: &AppHandle) -> Result<PathBuf, String> {
@@ -26,11 +52,56 @@ pub fn load_config(app: &AppHandle) -> Result<AppConfig, String> {
return Ok(AppConfig::default()); return Ok(AppConfig::default());
} }
let raw = fs::read_to_string(&path).map_err(|e| e.to_string())?; let raw = fs::read_to_string(&path).map_err(|e| e.to_string())?;
serde_json::from_str(&raw).map_err(|e| e.to_string()) let mut cfg: AppConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
if cfg.config_version == 0 {
cfg.config_version = CONFIG_VERSION;
}
Ok(cfg)
} }
pub fn save_config(app: &AppHandle, cfg: &AppConfig) -> Result<(), String> { pub fn save_config(app: &AppHandle, cfg: &AppConfig) -> Result<(), String> {
let path = config_path(app)?; let path = config_path(app)?;
let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?; let mut to_save = cfg.clone();
to_save.config_version = CONFIG_VERSION;
let raw = serde_json::to_string_pretty(&to_save).map_err(|e| e.to_string())?;
fs::write(path, raw).map_err(|e| e.to_string()) fs::write(path, raw).map_err(|e| e.to_string())
} }
pub fn clear_config(app: &AppHandle) -> Result<(), String> {
let path = config_path(app)?;
if path.exists() {
fs::remove_file(path).map_err(|e| e.to_string())?;
}
Ok(())
}
/// Read optional embedded installer config `{exe_dir}/betterdesk-rdclient.json`.
pub fn load_embedded_server_url() -> Option<String> {
let exe = std::env::current_exe().ok()?;
let path = exe.parent()?.join("betterdesk-rdclient.json");
if !path.exists() {
return None;
}
let raw = fs::read_to_string(path).ok()?;
let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
v.get("server_url")
.and_then(|x| x.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
pub fn env_server_url() -> Option<String> {
std::env::var("BETTERDESK_SERVER_URL")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
pub fn apply_tls_from_config(cfg: &AppConfig) {
if cfg.tls_strict {
// SAFETY: before any WebView/GTK init in setup().
unsafe {
std::env::set_var("BETTERDESK_TLS_STRICT", "1");
}
}
}
+114
View File
@@ -0,0 +1,114 @@
use serde::{Deserialize, Serialize};
use std::net::{Ipv4Addr, SocketAddr, UdpSocket};
use std::time::Duration;
const DISCOVERY_PORT: u16 = 21119;
const PROBE: &str = r#"{"type":"betterdesk-discover","version":1}"#;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveredServer {
pub name: String,
pub url: String,
pub source: String,
}
#[derive(Debug, Deserialize)]
struct Announcement {
#[serde(rename = "type")]
kind: String,
server: Option<AnnounceServer>,
}
#[derive(Debug, Deserialize)]
struct AnnounceServer {
name: Option<String>,
version: Option<String>,
port: Option<u16>,
#[serde(rename = "apiPort")]
api_port: Option<u16>,
protocol: Option<String>,
addresses: Option<Vec<String>>,
#[serde(rename = "panelUrl")]
panel_url: Option<String>,
}
/// UDP LAN discovery using the existing BetterDesk console protocol.
pub fn discover_udp(timeout_ms: u64) -> Vec<DiscoveredServer> {
let socket = match UdpSocket::bind("0.0.0.0:0") {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let _ = socket.set_broadcast(true);
let _ = socket.set_read_timeout(Some(Duration::from_millis(timeout_ms.max(500))));
let probe = PROBE.as_bytes();
let broadcast = SocketAddr::new(Ipv4Addr::BROADCAST.into(), DISCOVERY_PORT);
let _ = socket.send_to(probe, broadcast);
let mut out = Vec::new();
let mut buf = [0u8; 4096];
let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
while std::time::Instant::now() < deadline {
match socket.recv_from(&mut buf) {
Ok((len, _addr)) => {
if let Ok(text) = std::str::from_utf8(&buf[..len]) {
if let Ok(parsed) = serde_json::from_str::<Announcement>(text) {
if parsed.kind == "betterdesk-announce" {
if let Some(server) = parsed.server {
if let Some(entry) = server_to_discovered(&server) {
if !out.iter().any(|x: &DiscoveredServer| x.url == entry.url) {
out.push(entry);
}
}
}
}
}
}
}
Err(_) => break,
}
}
out
}
fn server_to_discovered(server: &AnnounceServer) -> Option<DiscoveredServer> {
if let Some(url) = server.panel_url.as_ref().filter(|u| !u.is_empty()) {
return Some(DiscoveredServer {
name: server.name.clone().unwrap_or_else(|| url.clone()),
url: url.trim_end_matches('/').to_string(),
source: "udp".into(),
});
}
let protocol = server.protocol.as_deref().unwrap_or("https");
let port = server.port.or(server.api_port)?;
let addr = server.addresses.as_ref()?.first()?.clone();
let url = format!("{protocol}://{addr}:{port}");
Some(DiscoveredServer {
name: server.name.clone().unwrap_or_else(|| addr.clone()),
url,
source: "udp".into(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_panel_url_from_announcement() {
let server = AnnounceServer {
name: Some("Test".into()),
version: None,
port: Some(5443),
api_port: None,
protocol: Some("https".into()),
addresses: Some(vec!["192.168.1.10".into()]),
panel_url: Some("https://desk.local:5443".into()),
};
let d = server_to_discovered(&server).unwrap();
assert_eq!(d.url, "https://desk.local:5443");
}
}
+237 -23
View File
@@ -1,22 +1,35 @@
mod config; mod config;
mod discovery;
mod server_probe;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod linux_display; pub mod linux_display;
pub mod tls_policy; pub mod tls_policy;
use config::{load_config, save_config}; use config::{
apply_tls_from_config, clear_config, env_server_url, load_config, load_embedded_server_url,
save_config, AppConfig,
};
use discovery::discover_udp;
use server_probe::probe_panel_url;
use tls_policy::apply_window_builder; use tls_policy::apply_window_builder;
use tauri::ipc::CapabilityBuilder; use tauri::ipc::CapabilityBuilder;
use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri_utils::config::BackgroundThrottlingPolicy; use tauri_utils::config::BackgroundThrottlingPolicy;
const MAIN_LABEL: &str = "main"; const MAIN_LABEL: &str = "main";
const CLIENT_BUILD: &str = "0.1.0-connect-bridge"; const SETTINGS_LABEL: &str = "settings";
const CLIENT_BUILD: &str = "0.1.0-production";
/// Injected before page JS — desktop flag + Connect bridge (works even if panel JS is older). /// Injected before page JS — desktop flag + Connect bridge (works even if panel JS is older).
const DESKTOP_INIT_SCRIPT: &str = r#" const DESKTOP_INIT_SCRIPT: &str = r#"
window.__BETTERDESK_RDCLIENT_DESKTOP__=true; window.__BETTERDESK_RDCLIENT_DESKTOP__=true;
(function(){function m(){document.documentElement.classList.add('rd-desk-desktop');if(document.body)document.body.classList.add('rd-desk-desktop');var a=document.getElementById('rd-desk-app');if(a)a.classList.add('rd-desk-desktop');}if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',m,{once:true});else m();})(); (function(){
function syncVh(){var h=window.innerHeight;if(h<1)return;document.documentElement.style.setProperty('--rd-desk-vh',h+'px');}
function mark(){document.documentElement.classList.add('rd-desk-desktop');if(document.body)document.body.classList.add('rd-desk-desktop');var a=document.getElementById('rd-desk-app');if(a)a.classList.add('rd-desk-desktop');syncVh();if(!window.__rdDeskViewportBound){window.__rdDeskViewportBound=true;window.addEventListener('resize',syncVh);}}
syncVh();
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',mark,{once:true});else mark();
})();
function __rdDesktopInvoke(){return window.__TAURI__&&window.__TAURI__.core&&window.__TAURI__.core.invoke;} function __rdDesktopInvoke(){return window.__TAURI__&&window.__TAURI__.core&&window.__TAURI__.core.invoke;}
function __rdIsSessionViewer(){return /\/remote\/[^/?#]+/.test(location.pathname);} function __rdIsSessionViewer(){return /\/remote\/[^/?#]+/.test(location.pathname);}
function __rdCloseWindow(){ function __rdCloseWindow(){
@@ -57,6 +70,21 @@ document.addEventListener('click',function(e){
}); });
} }
},true); },true);
(function(){
if(document.cookie.indexOf('betterdesk_lang=')>=0)return;
var inv=window.__TAURI__&&window.__TAURI__.core&&window.__TAURI__.core.invoke;
if(!inv)return;
inv('get_config').then(function(cfg){
var pref=cfg&&cfg.ui_lang;
if(pref){document.cookie='betterdesk_lang='+encodeURIComponent(pref)+';path=/;max-age=31536000';return;}
return inv('get_system_locale').then(function(locale){
if(!locale)return;
var code=String(locale).split(/[-_]/)[0].toLowerCase();
if(!code)return;
return fetch('/api/i18n/set/'+encodeURIComponent(code),{method:'POST',credentials:'include',headers:{'X-Requested-With':'XMLHttpRequest'}});
});
}).catch(function(){});
})();
"#; "#;
const PANEL_INVOKE_PERMISSIONS: &[&str] = &[ const PANEL_INVOKE_PERMISSIONS: &[&str] = &[
@@ -65,15 +93,25 @@ const PANEL_INVOKE_PERMISSIONS: &[&str] = &[
"core:window:allow-set-focus", "core:window:allow-set-focus",
"core:window:allow-close", "core:window:allow-close",
"core:webview:allow-create-webview-window", "core:webview:allow-create-webview-window",
"core:webview:allow-clear-all-browsing-data",
"shell:allow-open", "shell:allow-open",
"allow-get-client-info",
"allow-get-server-url", "allow-get-server-url",
"allow-set-server-url", "allow-set-server-url",
"allow-probe-server-url",
"allow-discover-servers",
"allow-get-config",
"allow-set-tls-strict",
"allow-set-ui-lang",
"allow-open-settings",
"allow-sign-out",
"allow-reset-client",
"allow-get-system-locale",
"allow-open-session", "allow-open-session",
"allow-close-current-window", "allow-close-current-window",
"allow-get-client-info",
]; ];
fn normalize_server_url(raw: &str) -> Result<String, String> { pub fn normalize_server_url(raw: &str) -> Result<String, String> {
let trimmed = raw.trim(); let trimmed = raw.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return Err("Server URL is required".into()); return Err("Server URL is required".into());
@@ -88,8 +126,7 @@ fn normalize_server_url(raw: &str) -> Result<String, String> {
format!("https://{trimmed}") format!("https://{trimmed}")
}; };
let parsed = url::parse_helper(&with_scheme)?; url::parse_helper(&with_scheme)
Ok(parsed)
} }
mod url { mod url {
@@ -128,6 +165,7 @@ fn register_panel_remote_capability(app: &AppHandle, base: &str) -> Result<(), S
let mut builder = CapabilityBuilder::new("panel-operator") let mut builder = CapabilityBuilder::new("panel-operator")
.local(true) .local(true)
.window(MAIN_LABEL) .window(MAIN_LABEL)
.window(SETTINGS_LABEL)
.window("session-*"); .window("session-*");
for pattern in patterns { for pattern in patterns {
@@ -180,15 +218,57 @@ fn apply_desktop_window<R: tauri::Runtime, M: tauri::Manager<R>>(
) )
} }
fn close_session_windows(app: &AppHandle) {
for label in app
.webview_windows()
.keys()
.filter(|l| l.starts_with("session-"))
.cloned()
.collect::<Vec<_>>()
{
if let Some(w) = app.get_webview_window(&label) {
let _ = w.close();
}
}
}
fn clear_all_webview_data(app: &AppHandle) -> Result<(), String> {
for (_label, window) in app.webview_windows() {
window
.clear_all_browsing_data()
.map_err(|e| format!("Failed to clear browsing data: {e}"))?;
}
Ok(())
}
async fn save_server_url(app: &AppHandle, url: String, via: Option<&str>) -> Result<(), String> {
let cfg = load_config(app)?;
let result = probe_panel_url(&url, cfg.tls_strict).await;
if !result.ok {
return Err(
result
.error
.unwrap_or_else(|| "Server did not respond as a BetterDesk panel".into()),
);
}
let normalized = result.normalized_url;
register_panel_remote_capability(app, &normalized)?;
let mut cfg = load_config(app)?;
cfg.server_url = Some(normalized.clone());
if let Some(source) = via {
cfg.discovered_via = Some(source.to_string());
}
save_config(app, &cfg)?;
open_main_window(app, WebviewUrl::External(dashboard_url(&normalized)?))?;
Ok(())
}
fn open_main_window(app: &AppHandle, url: WebviewUrl) -> Result<(), String> { fn open_main_window(app: &AppHandle, url: WebviewUrl) -> Result<(), String> {
if let Some(existing) = app.get_webview_window(MAIN_LABEL) { if let Some(existing) = app.get_webview_window(MAIN_LABEL) {
if let WebviewUrl::External(parsed) = url { if let WebviewUrl::External(parsed) = url {
existing existing.navigate(parsed).map_err(|e| e.to_string())?;
.navigate(parsed) existing.set_focus().map_err(|e| e.to_string())?;
.map_err(|e| e.to_string())?;
existing
.set_focus()
.map_err(|e| e.to_string())?;
return Ok(()); return Ok(());
} }
existing.close().map_err(|e| e.to_string())?; existing.close().map_err(|e| e.to_string())?;
@@ -206,10 +286,47 @@ fn open_main_window(app: &AppHandle, url: WebviewUrl) -> Result<(), String> {
Ok(()) Ok(())
} }
fn try_auto_configure_server(app: &AppHandle) -> Result<bool, String> {
let cfg = load_config(app)?;
if cfg.server_url.is_some() {
return Ok(false);
}
let candidate = match env_server_url().or_else(load_embedded_server_url) {
Some(c) => c,
None => return Ok(false),
};
let rt = tokio::runtime::Handle::current();
let probe = rt.block_on(probe_panel_url(&candidate, cfg.tls_strict));
if !probe.ok {
return Ok(false);
}
register_panel_remote_capability(app, &probe.normalized_url)?;
let mut updated = load_config(app)?;
updated.server_url = Some(probe.normalized_url);
updated.discovered_via = Some(
if env_server_url().is_some() {
"env"
} else {
"embedded"
}
.into(),
);
save_config(app, &updated)?;
Ok(true)
}
fn launch_main(app: &AppHandle) -> Result<(), String> { fn launch_main(app: &AppHandle) -> Result<(), String> {
let cfg = load_config(app)?; let cfg = load_config(app)?;
let url = if let Some(base) = cfg.server_url { apply_tls_from_config(&cfg);
WebviewUrl::External(dashboard_url(&base)?)
let url = if cfg.server_url.is_some() {
WebviewUrl::External(dashboard_url(cfg.server_url.as_ref().unwrap())?)
} else if try_auto_configure_server(app)? {
let updated = load_config(app)?;
WebviewUrl::External(dashboard_url(updated.server_url.as_ref().unwrap())?)
} else { } else {
WebviewUrl::App("setup.html".into()) WebviewUrl::App("setup.html".into())
}; };
@@ -230,16 +347,104 @@ fn get_server_url(app: AppHandle) -> Result<Option<String>, String> {
} }
#[tauri::command] #[tauri::command]
fn set_server_url(app: AppHandle, url: String) -> Result<(), String> { fn get_config(app: AppHandle) -> Result<AppConfig, String> {
let normalized = normalize_server_url(&url)?; load_config(&app)
register_panel_remote_capability(&app, &normalized)?; }
#[tauri::command]
async fn probe_server_url(app: AppHandle, url: String) -> Result<server_probe::ServerProbeResult, String> {
let cfg = load_config(&app)?;
Ok(probe_panel_url(&url, cfg.tls_strict).await)
}
#[tauri::command]
fn discover_servers() -> Vec<discovery::DiscoveredServer> {
discover_udp(2500)
}
#[tauri::command]
async fn set_server_url(app: AppHandle, url: String) -> Result<(), String> {
save_server_url(&app, url, Some("manual")).await
}
#[tauri::command]
fn set_tls_strict(app: AppHandle, strict: bool) -> Result<(), String> {
let mut cfg = load_config(&app)?; let mut cfg = load_config(&app)?;
cfg.server_url = Some(normalized.clone()); cfg.tls_strict = strict;
save_config(&app, &cfg)?; save_config(&app, &cfg)?;
open_main_window( if strict {
&app, unsafe {
WebviewUrl::External(dashboard_url(&normalized)?), std::env::set_var("BETTERDESK_TLS_STRICT", "1");
}
} else {
unsafe {
std::env::remove_var("BETTERDESK_TLS_STRICT");
}
}
Ok(())
}
#[tauri::command]
fn set_ui_lang(app: AppHandle, lang: Option<String>) -> Result<(), String> {
let mut cfg = load_config(&app)?;
cfg.ui_lang = lang.filter(|s| !s.trim().is_empty());
save_config(&app, &cfg)
}
#[tauri::command]
fn get_system_locale() -> Option<String> {
sys_locale::get_locale()
}
#[tauri::command]
fn open_settings(app: AppHandle) -> Result<(), String> {
if let Some(existing) = app.get_webview_window(SETTINGS_LABEL) {
existing.set_focus().map_err(|e| e.to_string())?;
return Ok(());
}
apply_desktop_window(
WebviewWindowBuilder::new(&app, SETTINGS_LABEL, WebviewUrl::App("settings.html".into()))
.title("BetterDesk RdClient — Settings")
.inner_size(520.0, 640.0)
.resizable(true)
.center(),
) )
.build()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn sign_out(app: AppHandle) -> Result<(), String> {
close_session_windows(&app);
clear_all_webview_data(&app)?;
let cfg = load_config(&app)?;
if let Some(base) = cfg.server_url {
open_main_window(
&app,
WebviewUrl::External(
tauri::Url::parse(&format!("{}/remote/login", base.trim_end_matches('/')))
.map_err(|e| e.to_string())?,
),
)?;
}
Ok(())
}
#[tauri::command]
async fn reset_client(app: AppHandle) -> Result<(), String> {
close_session_windows(&app);
clear_all_webview_data(&app)?;
clear_config(&app)?;
if let Some(settings) = app.get_webview_window(SETTINGS_LABEL) {
let _ = settings.close();
}
open_main_window(&app, WebviewUrl::App("setup.html".into()))?;
Ok(())
} }
#[tauri::command] #[tauri::command]
@@ -295,12 +500,21 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_client_info, get_client_info,
get_server_url, get_server_url,
get_config,
probe_server_url,
discover_servers,
set_server_url, set_server_url,
set_tls_strict,
set_ui_lang,
get_system_locale,
open_settings,
sign_out,
reset_client,
open_session, open_session,
close_current_window close_current_window
]) ])
.setup(|app| { .setup(|app| {
if let Some(base) = load_config(app.handle())?.server_url { if let Some(base) = load_config(app.handle())?.server_url.clone() {
register_panel_remote_capability(app.handle(), &base)?; register_panel_remote_capability(app.handle(), &base)?;
} }
launch_main(app.handle())?; launch_main(app.handle())?;
@@ -0,0 +1,111 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerProbeResult {
pub ok: bool,
pub normalized_url: String,
pub product: Option<String>,
pub version: Option<String>,
pub panel_name: Option<String>,
pub error: Option<String>,
}
pub async fn probe_panel_url(base: &str, tls_strict: bool) -> ServerProbeResult {
let normalized = match super::normalize_server_url(base) {
Ok(u) => u,
Err(e) => {
return ServerProbeResult {
ok: false,
normalized_url: base.trim().to_string(),
product: None,
version: None,
panel_name: None,
error: Some(e),
};
}
};
let url = format!("{}/api/bd/server-info", normalized.trim_end_matches('/'));
let client = match build_http_client(tls_strict) {
Ok(c) => c,
Err(e) => {
return ServerProbeResult {
ok: false,
normalized_url: normalized,
product: None,
version: None,
panel_name: None,
error: Some(e),
};
}
};
match client.get(&url).send().await {
Ok(resp) => {
if !resp.status().is_success() {
return ServerProbeResult {
ok: false,
normalized_url: normalized,
product: None,
version: None,
panel_name: None,
error: Some(format!("HTTP {}", resp.status())),
};
}
match resp.json::<serde_json::Value>().await {
Ok(body) => {
let ok = body.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
ServerProbeResult {
ok,
normalized_url: normalized,
product: body
.get("product")
.and_then(|v| v.as_str())
.map(String::from),
version: body
.get("version")
.and_then(|v| v.as_str())
.map(String::from),
panel_name: body
.get("panel_name")
.and_then(|v| v.as_str())
.map(String::from),
error: if ok {
None
} else {
Some("Not a BetterDesk panel".into())
},
}
}
Err(e) => ServerProbeResult {
ok: false,
normalized_url: normalized,
product: None,
version: None,
panel_name: None,
error: Some(format!("Invalid JSON: {e}")),
},
}
}
Err(e) => ServerProbeResult {
ok: false,
normalized_url: normalized,
product: None,
version: None,
panel_name: None,
error: Some(format!("Connection failed: {e}")),
},
}
}
fn build_http_client(tls_strict: bool) -> Result<reqwest::Client, String> {
let mut builder = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8))
.user_agent("BetterDesk-RdClient/0.1");
if !tls_strict {
builder = builder.danger_accept_invalid_certs(true);
}
builder.build().map_err(|e| e.to_string())
}
+122
View File
@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BetterDesk RdClient — Settings</title>
<style>
:root {
color-scheme: dark;
--bg: #0d1117;
--panel: #161b22;
--border: #30363d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #58a6ff;
--error: #f85149;
--warn: #d29922;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
padding: 20px;
}
.card {
max-width: 480px;
margin: 0 auto;
padding: 24px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--panel);
}
h1 { margin: 0 0 4px; font-size: 1.125rem; }
.subtitle { margin: 0 0 20px; color: var(--muted); font-size: 0.8125rem; }
section { margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
section:last-of-type { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
h2 { margin: 0 0 12px; font-size: 0.8125rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
label { display: block; margin-bottom: 6px; font-size: 0.8125rem; color: var(--muted); }
input, select {
width: 100%;
padding: 9px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: #21262d;
color: var(--text);
font-size: 0.875rem;
}
.hint { margin-top: 6px; font-size: 0.75rem; color: var(--muted); line-height: 1.4; }
.warn { color: var(--warn); }
button {
margin-top: 10px;
padding: 9px 14px;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
.btn-primary { background: var(--accent); color: #fff; width: 100%; }
.btn-secondary { background: #21262d; border: 1px solid var(--border); color: var(--text); width: 100%; }
.btn-danger { background: #3d1214; border: 1px solid var(--error); color: #ffb4b0; width: 100%; }
button:disabled { opacity: 0.6; cursor: wait; }
.checkbox-row { display: flex; align-items: flex-start; gap: 10px; margin-top: 8px; }
.checkbox-row input { width: auto; margin-top: 3px; }
.checkbox-row label { margin: 0; color: var(--text); }
.status { margin-top: 8px; font-size: 0.8125rem; min-height: 1.2em; }
.status.error { color: var(--error); }
.status.ok { color: #3fb950; }
.server-meta { font-size: 0.75rem; color: var(--muted); margin-top: 4px; }
</style>
</head>
<body>
<div class="card">
<h1>RdClient Settings</h1>
<p class="subtitle">Local desktop preferences and account actions.</p>
<section>
<h2>Server</h2>
<label for="server-url">Panel URL</label>
<input type="url" id="server-url" placeholder="https://desk.example.com">
<div class="server-meta" id="server-meta"></div>
<button type="button" class="btn-primary" id="save-url-btn">Save &amp; reconnect</button>
<div class="status" id="url-status"></div>
</section>
<section>
<h2>Security</h2>
<div class="checkbox-row">
<input type="checkbox" id="tls-strict">
<label for="tls-strict">Strict TLS (reject self-signed certificates)</label>
</div>
<p class="hint warn">Only enable if your panel uses a trusted CA certificate. Self-signed panels require this to stay off.</p>
<button type="button" class="btn-secondary" id="save-tls-btn">Save TLS setting</button>
</section>
<section>
<h2>Language</h2>
<label for="ui-lang">Preferred UI language (panel pages)</label>
<select id="ui-lang">
<option value="">System default</option>
</select>
<p class="hint">Applied on next panel page load. Use the language menu in the dashboard for immediate change.</p>
</section>
<section>
<h2>Session</h2>
<button type="button" class="btn-secondary" id="sign-out-btn">Sign out</button>
<p class="hint">Clears panel login cookies and opens the login page.</p>
</section>
<section>
<h2>Reset</h2>
<button type="button" class="btn-danger" id="reset-btn">Reset client</button>
<p class="hint">Removes server URL, cookies, saved passwords, and returns to setup.</p>
</section>
</div>
<script src="settings.js"></script>
</body>
</html>
+137
View File
@@ -0,0 +1,137 @@
(function () {
'use strict';
var LANGS = [
['', 'System default'],
['en', 'English'],
['pl', 'Polski'],
['de', 'Deutsch'],
['fr', 'Français'],
['es', 'Español'],
['it', 'Italiano'],
['pt', 'Português'],
['nl', 'Nederlands'],
['cs', 'Čeština'],
['da', 'Dansk'],
['fi', 'Suomi'],
['nb', 'Norsk'],
['sv', 'Svenska'],
['ro', 'Română'],
['hu', 'Magyar'],
['uk', 'Українська'],
['ru', 'Русский'],
['tr', 'Türkçe'],
['ar', 'العربية'],
['hi', 'हिन्दी'],
['ja', '日本語'],
['ko', '한국어'],
['zh', '简体中文'],
['zh-TW', '繁體中文'],
['vi', 'Tiếng Việt'],
['th', 'ไทย'],
['id', 'Bahasa Indonesia']
];
var urlInput = document.getElementById('server-url');
var urlStatus = document.getElementById('url-status');
var serverMeta = document.getElementById('server-meta');
var tlsCheckbox = document.getElementById('tls-strict');
var langSelect = document.getElementById('ui-lang');
function status(el, msg, kind) {
if (!el) return;
el.textContent = msg || '';
el.className = 'status' + (kind ? ' ' + kind : '');
}
async function invoke(cmd, args) {
if (!window.__TAURI__ || !window.__TAURI__.core || !window.__TAURI__.core.invoke) {
throw new Error('Desktop bridge unavailable');
}
return window.__TAURI__.core.invoke(cmd, args || {});
}
function fillLangSelect(selected) {
if (!langSelect) return;
langSelect.innerHTML = '';
LANGS.forEach(function (pair) {
var opt = document.createElement('option');
opt.value = pair[0];
opt.textContent = pair[1];
if (pair[0] === (selected || '')) opt.selected = true;
langSelect.appendChild(opt);
});
}
async function loadConfig() {
try {
var cfg = await invoke('get_config');
if (urlInput && cfg.server_url) urlInput.value = cfg.server_url;
if (tlsCheckbox) tlsCheckbox.checked = !!cfg.tls_strict;
fillLangSelect(cfg.ui_lang || '');
if (cfg.server_url) {
var probe = await invoke('probe_server_url', { url: cfg.server_url });
if (probe && probe.ok && serverMeta) {
var parts = [];
if (probe.panel_name) parts.push(probe.panel_name);
if (probe.version) parts.push('v' + probe.version);
serverMeta.textContent = parts.join(' · ');
}
}
} catch (e) {
status(urlStatus, String(e), 'error');
}
}
document.getElementById('save-url-btn').addEventListener('click', async function () {
var url = (urlInput.value || '').trim();
if (!url) {
status(urlStatus, 'Enter a panel URL.', 'error');
return;
}
status(urlStatus, 'Validating…', '');
try {
await invoke('set_server_url', { url: url });
status(urlStatus, 'Saved. Reconnecting…', 'ok');
} catch (e) {
status(urlStatus, String(e), 'error');
}
});
document.getElementById('save-tls-btn').addEventListener('click', async function () {
try {
await invoke('set_tls_strict', { strict: !!tlsCheckbox.checked });
status(urlStatus, 'TLS setting saved. Restart may be required for existing connections.', 'ok');
} catch (e) {
status(urlStatus, String(e), 'error');
}
});
if (langSelect) {
langSelect.addEventListener('change', async function () {
try {
await invoke('set_ui_lang', { lang: langSelect.value || null });
} catch (_) { /* ignore */ }
});
}
document.getElementById('sign-out-btn').addEventListener('click', async function () {
if (!confirm('Sign out from the panel on this device?')) return;
try {
await invoke('sign_out');
} catch (e) {
status(urlStatus, String(e), 'error');
}
});
document.getElementById('reset-btn').addEventListener('click', async function () {
if (!confirm('Reset RdClient? This removes the server URL and all saved data on this device.')) return;
try {
await invoke('reset_client');
} catch (e) {
status(urlStatus, String(e), 'error');
}
});
loadConfig();
})();
+38 -32
View File
@@ -29,29 +29,16 @@
} }
.card { .card {
width: 100%; width: 100%;
max-width: 440px; max-width: 480px;
padding: 28px; padding: 28px;
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--panel); background: var(--panel);
} }
h1 { h1 { margin: 0 0 8px; font-size: 1.25rem; font-weight: 600; }
margin: 0 0 8px; h2 { margin: 20px 0 8px; font-size: 0.875rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
font-size: 1.25rem; p { margin: 0 0 16px; color: var(--muted); font-size: 0.875rem; line-height: 1.5; }
font-weight: 600; label { display: block; margin-bottom: 6px; font-size: 0.8125rem; color: var(--muted); }
}
p {
margin: 0 0 20px;
color: var(--muted);
font-size: 0.875rem;
line-height: 1.5;
}
label {
display: block;
margin-bottom: 6px;
font-size: 0.8125rem;
color: var(--muted);
}
input { input {
width: 100%; width: 100%;
padding: 10px 12px; padding: 10px 12px;
@@ -61,13 +48,8 @@
color: var(--text); color: var(--text);
font-size: 0.875rem; font-size: 0.875rem;
} }
input:focus { input:focus { outline: none; border-color: var(--accent); }
outline: none;
border-color: var(--accent);
}
button { button {
margin-top: 16px;
width: 100%;
padding: 10px 14px; padding: 10px 14px;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
@@ -77,23 +59,47 @@
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
} }
button.secondary { background: #21262d; border: 1px solid var(--border); color: var(--text); }
button:disabled { opacity: 0.6; cursor: wait; } button:disabled { opacity: 0.6; cursor: wait; }
.error { .row { display: flex; gap: 8px; margin-top: 16px; }
margin-top: 12px; .row button { flex: 1; }
color: var(--error); .error { margin-top: 12px; color: var(--error); font-size: 0.8125rem; min-height: 1.2em; }
font-size: 0.8125rem; .discovered-list { list-style: none; margin: 0; padding: 0; }
min-height: 1.2em; .discovered-list li { margin-bottom: 8px; }
.discovered-list button {
width: 100%;
text-align: left;
background: #21262d;
border: 1px solid var(--border);
color: var(--text);
font-weight: 500;
} }
.discovered-list button:hover { border-color: var(--accent); }
.discovered-name { display: block; font-size: 0.875rem; }
.discovered-url { display: block; font-size: 0.75rem; color: var(--muted); margin-top: 2px; }
.empty-hint { font-size: 0.8125rem; color: var(--muted); font-style: italic; }
</style> </style>
</head> </head>
<body> <body>
<div class="card"> <div class="card">
<h1>BetterDesk RdClient</h1> <h1>BetterDesk RdClient</h1>
<p>Enter the URL of your BetterDesk console panel. The dashboard will load from <code>/remote</code> on this server.</p> <p>Connect to your BetterDesk console panel. The dashboard loads from <code>/remote</code> on the server you choose.</p>
<h2>Servers on your network</h2>
<ul class="discovered-list" id="discovered-list">
<li class="empty-hint" id="discovered-empty">Scanning…</li>
</ul>
<div class="row">
<button type="button" class="secondary" id="refresh-btn">Refresh</button>
</div>
<h2>Manual URL</h2>
<form id="setup-form"> <form id="setup-form">
<label for="server-url">Panel URL</label> <label for="server-url">Panel URL</label>
<input type="url" id="server-url" placeholder="https://desk.example.com" required autofocus> <input type="url" id="server-url" placeholder="https://desk.example.com" required>
<button type="submit" id="submit-btn">Continue</button> <div class="row">
<button type="submit" id="submit-btn">Continue</button>
</div>
<div class="error" id="error"></div> <div class="error" id="error"></div>
</form> </form>
</div> </div>
+74 -11
View File
@@ -5,6 +5,9 @@
var input = document.getElementById('server-url'); var input = document.getElementById('server-url');
var errorEl = document.getElementById('error'); var errorEl = document.getElementById('error');
var submitBtn = document.getElementById('submit-btn'); var submitBtn = document.getElementById('submit-btn');
var listEl = document.getElementById('discovered-list');
var emptyEl = document.getElementById('discovered-empty');
var refreshBtn = document.getElementById('refresh-btn');
function showError(msg) { function showError(msg) {
if (errorEl) errorEl.textContent = msg || ''; if (errorEl) errorEl.textContent = msg || '';
@@ -17,34 +20,94 @@
return null; return null;
} }
form.addEventListener('submit', async function (e) { async function connectUrl(url) {
e.preventDefault();
showError('');
var url = (input.value || '').trim();
if (!url) {
showError('Please enter a panel URL.');
return;
}
var invoke = await getInvoke(); var invoke = await getInvoke();
if (!invoke) { if (!invoke) {
showError('Desktop bridge unavailable.'); showError('Desktop bridge unavailable.');
return; return;
} }
submitBtn.disabled = true; submitBtn.disabled = true;
if (refreshBtn) refreshBtn.disabled = true;
showError('');
try { try {
await invoke('set_server_url', { url: url }); var probe = await invoke('probe_server_url', { url: url });
if (!probe || !probe.ok) {
showError((probe && probe.error) || 'Server did not respond as a BetterDesk panel.');
submitBtn.disabled = false;
if (refreshBtn) refreshBtn.disabled = false;
return;
}
await invoke('set_server_url', { url: probe.normalized_url || url });
} catch (err) { } catch (err) {
showError(String(err) || 'Failed to save server URL.'); showError(String(err) || 'Failed to save server URL.');
submitBtn.disabled = false; submitBtn.disabled = false;
if (refreshBtn) refreshBtn.disabled = false;
} }
}
function renderDiscovered(servers) {
if (!listEl) return;
listEl.innerHTML = '';
if (!servers || !servers.length) {
var li = document.createElement('li');
li.className = 'empty-hint';
li.textContent = 'No servers found on the local network.';
listEl.appendChild(li);
return;
}
servers.forEach(function (s) {
var li = document.createElement('li');
var btn = document.createElement('button');
btn.type = 'button';
btn.innerHTML = '<span class="discovered-name">' + escapeHtml(s.name || s.url) + '</span>'
+ '<span class="discovered-url">' + escapeHtml(s.url) + '</span>';
btn.addEventListener('click', function () {
if (input) input.value = s.url;
connectUrl(s.url);
});
li.appendChild(btn);
listEl.appendChild(li);
});
}
function escapeHtml(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
async function scanNetwork() {
var invoke = await getInvoke();
if (!invoke) {
if (emptyEl) emptyEl.textContent = 'Discovery unavailable.';
return;
}
if (emptyEl) emptyEl.textContent = 'Scanning…';
try {
var servers = await invoke('discover_servers');
renderDiscovered(servers || []);
} catch (_) {
renderDiscovered([]);
}
}
form.addEventListener('submit', async function (e) {
e.preventDefault();
var url = (input.value || '').trim();
if (!url) {
showError('Please enter a panel URL.');
return;
}
connectUrl(url);
}); });
if (refreshBtn) {
refreshBtn.addEventListener('click', function () { scanNetwork(); });
}
getInvoke().then(function (invoke) { getInvoke().then(function (invoke) {
if (!invoke) return; if (!invoke) return;
invoke('get_server_url').then(function (existing) { invoke('get_server_url').then(function (existing) {
if (existing && input) input.value = existing; if (existing && input) input.value = existing;
}).catch(function () { /* ignore */ }); }).catch(function () { /* ignore */ });
scanNetwork();
}); });
})(); })();
+20 -3
View File
@@ -745,7 +745,11 @@
"build_status_failed": "فشل", "build_status_failed": "فشل",
"builds_summary": "{{ready}} جاهز · {{pending}} في الانتظار · {{building}} جارٍ البناء · {{failed}} فشل", "builds_summary": "{{ready}} جاهز · {{pending}} في الانتظار · {{building}} جارٍ البناء · {{failed}} فشل",
"build_error_hint": "خطأ في البناء", "build_error_hint": "خطأ في البناء",
"preview_help": "طلب المساعدة" "preview_help": "طلب المساعدة",
"rdclient_tab": "RdClient سطح المكتب",
"rdclient_new_bundle": "حزمة RdClient جديدة",
"rdclient_subtitle": "إنشاء مثبتات RdClient مع رابط اللوحة المضمن",
"rdclient_server_url_hint": "الرابط العام لوحة BetterDesk (/remote)."
}, },
"server": { "server": {
"port": "Port", "port": "Port",
@@ -1051,7 +1055,9 @@
"file_loading": "تحميل...", "file_loading": "تحميل...",
"file_timeout": "الجهاز البعيد لم يستجب. قد لا يكون نقل الملفات مدعومًا أو معطلاً على الجهاز البعيد.", "file_timeout": "الجهاز البعيد لم يستجب. قد لا يكون نقل الملفات مدعومًا أو معطلاً على الجهاز البعيد.",
"codec": "ترميز الفيديو", "codec": "ترميز الفيديو",
"codec_auto": "تلقائي (مستحسن)" "codec_auto": "تلقائي (مستحسن)",
"remember_peer_password": "تذكر كلمة مرور الجهاز على هذا الحاسوب",
"save_peer_password": "حفظ كلمة المرور"
}, },
"branding": { "branding": {
"identity_title": "هوية العلامة التجارية", "identity_title": "هوية العلامة التجارية",
@@ -3835,7 +3841,8 @@
"no_groups": "لا توجد مجموعات", "no_groups": "لا توجد مجموعات",
"no_tags": "لا توجد وسوم", "no_tags": "لا توجد وسوم",
"collapse_devices": "طي قائمة الأجهزة", "collapse_devices": "طي قائمة الأجهزة",
"expand_devices": "توسيع قائمة الأجهزة" "expand_devices": "توسيع قائمة الأجهزة",
"open_settings": "الإعدادات"
}, },
"rdclient_login": { "rdclient_login": {
"title": "عميل سطح المكتب البعيد", "title": "عميل سطح المكتب البعيد",
@@ -3855,5 +3862,15 @@
"network_error": "خطأ في الشبكة", "network_error": "خطأ في الشبكة",
"enter_6_digits": "أدخل جميع الأرقام الستة", "enter_6_digits": "أدخل جميع الأرقام الستة",
"invalid_code": "رمز غير صالح" "invalid_code": "رمز غير صالح"
},
"rdclient_settings": {
"title": "إعدادات RdClient",
"open": "الإعدادات",
"server_url": "رابط اللوحة",
"tls_strict": "TLS صارم",
"sign_out": "تسجيل الخروج",
"reset_client": "إعادة ضبط العميل",
"discovery_refresh": "تحديث",
"discovery_empty": "لم يتم العثور على خوادم في الشبكة المحلية"
} }
} }
+20 -3
View File
@@ -738,7 +738,11 @@
"build_status_failed": "Neudany", "build_status_failed": "Neudany",
"builds_summary": "{{ready}} gotowych · {{pending}} w kolejce · {{building}} sestavování · {{failed}} nieudanych", "builds_summary": "{{ready}} gotowych · {{pending}} w kolejce · {{building}} sestavování · {{failed}} nieudanych",
"build_error_hint": "Blad buildu", "build_error_hint": "Blad buildu",
"preview_help": "Požádat o pomoc" "preview_help": "Požádat o pomoc",
"rdclient_tab": "RdClient desktop",
"rdclient_new_bundle": "Nový balíček RdClient",
"rdclient_subtitle": "Sestavit instalátory RdClient s vloženou URL panelu",
"rdclient_server_url_hint": "Veřejná URL konzole BetterDesk (/remote)."
}, },
"server": { "server": {
"port": "Port", "port": "Port",
@@ -1044,7 +1048,9 @@
"file_loading": "Načítání...", "file_loading": "Načítání...",
"file_timeout": "Vzdálené zařízení neodpovědělo. Přenos souborů nemusí být podporován nebo je na vzdáleném zařízení zakázán.", "file_timeout": "Vzdálené zařízení neodpovědělo. Přenos souborů nemusí být podporován nebo je na vzdáleném zařízení zakázán.",
"codec": "Kodek videa", "codec": "Kodek videa",
"codec_auto": "Automaticky (doporučeno)" "codec_auto": "Automaticky (doporučeno)",
"remember_peer_password": "Zapamatovat heslo zařízení na tomto počítači",
"save_peer_password": "Uložit heslo"
}, },
"branding": { "branding": {
"identity_title": "Identita značky", "identity_title": "Identita značky",
@@ -3828,7 +3834,8 @@
"no_groups": "Žádné skupiny", "no_groups": "Žádné skupiny",
"no_tags": "Žádné štítky", "no_tags": "Žádné štítky",
"collapse_devices": "Sbalit seznam zařízení", "collapse_devices": "Sbalit seznam zařízení",
"expand_devices": "Rozbalit seznam zařízení" "expand_devices": "Rozbalit seznam zařízení",
"open_settings": "Nastavení"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Klient vzdálené plochy", "title": "Klient vzdálené plochy",
@@ -3848,5 +3855,15 @@
"network_error": "Chyba sítě", "network_error": "Chyba sítě",
"enter_6_digits": "Zadejte všech 6 číslic", "enter_6_digits": "Zadejte všech 6 číslic",
"invalid_code": "Neplatný kód" "invalid_code": "Neplatný kód"
},
"rdclient_settings": {
"title": "Nastavení RdClient",
"open": "Nastavení",
"server_url": "URL panelu",
"tls_strict": "Přísné TLS",
"sign_out": "Odhlásit",
"reset_client": "Resetovat klienta",
"discovery_refresh": "Obnovit",
"discovery_empty": "V místní síti nebyly nalezeny žádné servery"
} }
} }
+20 -3
View File
@@ -744,7 +744,11 @@
"rebuild_queued": "Alle Plattform-Builds in der Warteschlange", "rebuild_queued": "Alle Plattform-Builds in der Warteschlange",
"builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} mislykkedes", "builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} mislykkedes",
"build_error_hint": "Buildfejl", "build_error_hint": "Buildfejl",
"preview_help": "Anmod om hjælp" "preview_help": "Anmod om hjælp",
"rdclient_tab": "RdClient desktop",
"rdclient_new_bundle": "Ny RdClient-pakke",
"rdclient_subtitle": "Byg RdClient-installere med indlejret panel-URL",
"rdclient_server_url_hint": "Offentlig URL til BetterDesk-konsollen (/remote)."
}, },
"server": { "server": {
"port": "Port", "port": "Port",
@@ -1050,7 +1054,9 @@
"file_loading": "Indlæser...", "file_loading": "Indlæser...",
"file_timeout": "Fjernenheden reagerede ikke. Filoverførsel understøttes muligvis ikke eller er deaktiveret på fjernmaskinen.", "file_timeout": "Fjernenheden reagerede ikke. Filoverførsel understøttes muligvis ikke eller er deaktiveret på fjernmaskinen.",
"codec": "Video Codec", "codec": "Video Codec",
"codec_auto": "Auto (anbefales)" "codec_auto": "Auto (anbefales)",
"remember_peer_password": "Husk enhedsadgangskode på denne computer",
"save_peer_password": "Gem adgangskode"
}, },
"branding": { "branding": {
"identity_title": "Brand Identitet", "identity_title": "Brand Identitet",
@@ -3834,7 +3840,8 @@
"no_groups": "Ingen grupper", "no_groups": "Ingen grupper",
"no_tags": "Ingen tags", "no_tags": "Ingen tags",
"collapse_devices": "Fold enhedsliste sammen", "collapse_devices": "Fold enhedsliste sammen",
"expand_devices": "Udvid enhedsliste" "expand_devices": "Udvid enhedsliste",
"open_settings": "Indstillinger"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Remote Desktop-klient", "title": "Remote Desktop-klient",
@@ -3854,5 +3861,15 @@
"network_error": "Netværksfejl", "network_error": "Netværksfejl",
"enter_6_digits": "Indtast alle 6 cifre", "enter_6_digits": "Indtast alle 6 cifre",
"invalid_code": "Ugyldig kode" "invalid_code": "Ugyldig kode"
},
"rdclient_settings": {
"title": "RdClient-indstillinger",
"open": "Indstillinger",
"server_url": "Panel-URL",
"tls_strict": "Streng TLS",
"sign_out": "Log ud",
"reset_client": "Nulstil klient",
"discovery_refresh": "Opdater",
"discovery_empty": "Ingen servere fundet på det lokale netværk"
} }
} }
+20 -3
View File
@@ -738,7 +738,11 @@
"text_color": "Textfarbe", "text_color": "Textfarbe",
"text_muted_color": "Gedämpfte Textfarbe", "text_muted_color": "Gedämpfte Textfarbe",
"use_https": "HTTPS / WSS verwenden (empfohlen)", "use_https": "HTTPS / WSS verwenden (empfohlen)",
"preview_help": "Hilfe anfordern" "preview_help": "Hilfe anfordern",
"rdclient_tab": "RdClient Desktop",
"rdclient_new_bundle": "Neues RdClient-Paket",
"rdclient_subtitle": "Branded RdClient-Installer mit eingebetteter Panel-URL erstellen",
"rdclient_server_url_hint": "Öffentliche URL der BetterDesk-Konsole (/remote)."
}, },
"server": { "server": {
"port": "Port", "port": "Port",
@@ -1044,7 +1048,9 @@
"file_loading": "Wird geladen...", "file_loading": "Wird geladen...",
"file_timeout": "Das entfernte Gerät hat nicht geantwortet. Dateiübertragung wird möglicherweise nicht unterstützt oder ist auf dem entfernten Computer deaktiviert.", "file_timeout": "Das entfernte Gerät hat nicht geantwortet. Dateiübertragung wird möglicherweise nicht unterstützt oder ist auf dem entfernten Computer deaktiviert.",
"codec": "Video-Codec", "codec": "Video-Codec",
"codec_auto": "Automatisch (empfohlen)" "codec_auto": "Automatisch (empfohlen)",
"remember_peer_password": "Gerätepasswort auf diesem Gerät merken",
"save_peer_password": "Passwort speichern"
}, },
"branding": { "branding": {
"identity_title": "Markenidentität", "identity_title": "Markenidentität",
@@ -3828,7 +3834,8 @@
"no_groups": "Keine Gruppen", "no_groups": "Keine Gruppen",
"no_tags": "Keine Tags", "no_tags": "Keine Tags",
"collapse_devices": "Geräteliste einklappen", "collapse_devices": "Geräteliste einklappen",
"expand_devices": "Geräteliste ausklappen" "expand_devices": "Geräteliste ausklappen",
"open_settings": "Einstellungen"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Remote-Desktop-Client", "title": "Remote-Desktop-Client",
@@ -3848,5 +3855,15 @@
"network_error": "Netzwerkfehler", "network_error": "Netzwerkfehler",
"enter_6_digits": "Alle 6 Ziffern eingeben", "enter_6_digits": "Alle 6 Ziffern eingeben",
"invalid_code": "Ungültiger Code" "invalid_code": "Ungültiger Code"
},
"rdclient_settings": {
"title": "RdClient-Einstellungen",
"open": "Einstellungen",
"server_url": "Panel-URL",
"tls_strict": "Striktes TLS",
"sign_out": "Abmelden",
"reset_client": "Client zurücksetzen",
"discovery_refresh": "Aktualisieren",
"discovery_empty": "Keine Server im lokalen Netzwerk gefunden"
} }
} }
+20 -3
View File
@@ -738,7 +738,11 @@
"usage_title": "Connection Details", "usage_title": "Connection Details",
"generated": "Configuration generated", "generated": "Configuration generated",
"server_required": "Server address is required", "server_required": "Server address is required",
"nothing_to_copy": "Generate a configuration first" "nothing_to_copy": "Generate a configuration first",
"rdclient_tab": "RdClient desktop",
"rdclient_new_bundle": "New RdClient bundle",
"rdclient_subtitle": "Build branded RdClient desktop installers with embedded panel URL",
"rdclient_server_url_hint": "Public URL where operators reach the BetterDesk console (/remote dashboard)."
}, },
"download": { "download": {
"title": "Download BetterDesk Agent", "title": "Download BetterDesk Agent",
@@ -1054,7 +1058,9 @@
"2fa_required": "2FA Verification Required", "2fa_required": "2FA Verification Required",
"2fa_hint": "Enter the 6-digit code from your authenticator app", "2fa_hint": "Enter the 6-digit code from your authenticator app",
"2fa_invalid": "Enter a valid 6-digit code", "2fa_invalid": "Enter a valid 6-digit code",
"verify": "Verify" "verify": "Verify",
"remember_peer_password": "Remember device password on this device",
"save_peer_password": "Save password"
}, },
"remote_dashboard": { "remote_dashboard": {
"title": "Remote Desktop Client", "title": "Remote Desktop Client",
@@ -1091,7 +1097,8 @@
"no_groups": "No groups", "no_groups": "No groups",
"no_tags": "No tags", "no_tags": "No tags",
"collapse_devices": "Collapse device list", "collapse_devices": "Collapse device list",
"expand_devices": "Expand device list" "expand_devices": "Expand device list",
"open_settings": "Settings"
}, },
"branding": { "branding": {
"identity_title": "Brand Identity", "identity_title": "Brand Identity",
@@ -3848,5 +3855,15 @@
"network_error": "Network error", "network_error": "Network error",
"enter_6_digits": "Enter all 6 digits", "enter_6_digits": "Enter all 6 digits",
"invalid_code": "Invalid code" "invalid_code": "Invalid code"
},
"rdclient_settings": {
"title": "RdClient Settings",
"open": "Settings",
"server_url": "Panel URL",
"tls_strict": "Strict TLS",
"sign_out": "Sign out",
"reset_client": "Reset client",
"discovery_refresh": "Refresh",
"discovery_empty": "No servers found on the local network"
} }
} }
+20 -3
View File
@@ -738,7 +738,11 @@
"text_color": "Color del texto", "text_color": "Color del texto",
"text_muted_color": "Color de texto atenuado", "text_muted_color": "Color de texto atenuado",
"use_https": "Usar HTTPS / WSS (recomendado)", "use_https": "Usar HTTPS / WSS (recomendado)",
"preview_help": "Solicitar ayuda" "preview_help": "Solicitar ayuda",
"rdclient_tab": "RdClient escritorio",
"rdclient_new_bundle": "Nuevo paquete RdClient",
"rdclient_subtitle": "Generar instaladores RdClient con URL del panel integrada",
"rdclient_server_url_hint": "URL pública de la consola BetterDesk (/remote)."
}, },
"server": { "server": {
"port": "Puerto", "port": "Puerto",
@@ -1044,7 +1048,9 @@
"file_loading": "Cargando...", "file_loading": "Cargando...",
"file_timeout": "El dispositivo remoto no respondió. Es posible que la transferencia de archivos no sea compatible o esté deshabilitada en el equipo remoto.", "file_timeout": "El dispositivo remoto no respondió. Es posible que la transferencia de archivos no sea compatible o esté deshabilitada en el equipo remoto.",
"codec": "Códec de vídeo", "codec": "Códec de vídeo",
"codec_auto": "Automático (recomendado)" "codec_auto": "Automático (recomendado)",
"remember_peer_password": "Recordar contraseña del dispositivo en este equipo",
"save_peer_password": "Guardar contraseña"
}, },
"branding": { "branding": {
"identity_title": "Identidad de marca", "identity_title": "Identidad de marca",
@@ -3828,7 +3834,8 @@
"no_groups": "Sin grupos", "no_groups": "Sin grupos",
"no_tags": "Sin etiquetas", "no_tags": "Sin etiquetas",
"collapse_devices": "Contraer lista de dispositivos", "collapse_devices": "Contraer lista de dispositivos",
"expand_devices": "Expandir lista de dispositivos" "expand_devices": "Expandir lista de dispositivos",
"open_settings": "Ajustes"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Cliente de escritorio remoto", "title": "Cliente de escritorio remoto",
@@ -3848,5 +3855,15 @@
"network_error": "Error de red", "network_error": "Error de red",
"enter_6_digits": "Introduzca los 6 dígitos", "enter_6_digits": "Introduzca los 6 dígitos",
"invalid_code": "Código inválido" "invalid_code": "Código inválido"
},
"rdclient_settings": {
"title": "Ajustes de RdClient",
"open": "Ajustes",
"server_url": "URL del panel",
"tls_strict": "TLS estricto",
"sign_out": "Cerrar sesión",
"reset_client": "Restablecer cliente",
"discovery_refresh": "Actualizar",
"discovery_empty": "No se encontraron servidores en la red local"
} }
} }
+20 -3
View File
@@ -744,7 +744,11 @@
"rebuild_queued": "Alle Plattform-Builds in der Warteschlange", "rebuild_queued": "Alle Plattform-Builds in der Warteschlange",
"builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} epäonnistui", "builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} epäonnistui",
"build_error_hint": "Koonnosvirhe", "build_error_hint": "Koonnosvirhe",
"preview_help": "Pyydä apua" "preview_help": "Pyydä apua",
"rdclient_tab": "RdClient-työpöytä",
"rdclient_new_bundle": "Uusi RdClient-paketti",
"rdclient_subtitle": "Rakenna RdClient-asentajat upotetulla paneelin URL:llä",
"rdclient_server_url_hint": "BetterDesk-konsolin julkinen URL (/remote)."
}, },
"server": { "server": {
"port": "Port", "port": "Port",
@@ -1050,7 +1054,9 @@
"file_loading": "Ladataan...", "file_loading": "Ladataan...",
"file_timeout": "Etälaite ei vastannut. Tiedostonsiirtoa ei ehkä tueta tai se on poistettu käytöstä etäkoneessa.", "file_timeout": "Etälaite ei vastannut. Tiedostonsiirtoa ei ehkä tueta tai se on poistettu käytöstä etäkoneessa.",
"codec": "Video Codec", "codec": "Video Codec",
"codec_auto": "Automaattinen (suositus)" "codec_auto": "Automaattinen (suositus)",
"remember_peer_password": "Muista laitteen salasana tällä tietokoneella",
"save_peer_password": "Tallenna salasana"
}, },
"branding": { "branding": {
"identity_title": "Brändin identiteetti", "identity_title": "Brändin identiteetti",
@@ -3834,7 +3840,8 @@
"no_groups": "Ei ryhmiä", "no_groups": "Ei ryhmiä",
"no_tags": "Ei tageja", "no_tags": "Ei tageja",
"collapse_devices": "Tiivistä laiteluettelo", "collapse_devices": "Tiivistä laiteluettelo",
"expand_devices": "Laajenna laiteluettelo" "expand_devices": "Laajenna laiteluettelo",
"open_settings": "Asetukset"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Etätyöpöytäasiakas", "title": "Etätyöpöytäasiakas",
@@ -3854,5 +3861,15 @@
"network_error": "Verkkovirhe", "network_error": "Verkkovirhe",
"enter_6_digits": "Syötä kaikki 6 numeroa", "enter_6_digits": "Syötä kaikki 6 numeroa",
"invalid_code": "Virheellinen koodi" "invalid_code": "Virheellinen koodi"
},
"rdclient_settings": {
"title": "RdClient-asetukset",
"open": "Asetukset",
"server_url": "Paneelin URL",
"tls_strict": "Tiukka TLS",
"sign_out": "Kirjaudu ulos",
"reset_client": "Nollaa asiakas",
"discovery_refresh": "Päivitä",
"discovery_empty": "Paikallisesta verkosta ei löytynyt palvelimia"
} }
} }
+20 -3
View File
@@ -742,7 +742,11 @@
"usage_title": "Détails de connexion", "usage_title": "Détails de connexion",
"generated": "Configuration générée", "generated": "Configuration générée",
"server_required": "L'adresse du serveur est requise", "server_required": "L'adresse du serveur est requise",
"nothing_to_copy": "Générez d'abord une configuration" "nothing_to_copy": "Générez d'abord une configuration",
"rdclient_tab": "RdClient bureau",
"rdclient_new_bundle": "Nouveau bundle RdClient",
"rdclient_subtitle": "Créer des installateurs RdClient avec URL du panel intégrée",
"rdclient_server_url_hint": "URL publique de la console BetterDesk (/remote)."
}, },
"download": { "download": {
"title": "BetterDesk Agent herunterladen", "title": "BetterDesk Agent herunterladen",
@@ -1058,7 +1062,9 @@
"2fa_required": "2FA Verification Required", "2fa_required": "2FA Verification Required",
"2fa_hint": "Enter the 6-digit code from your authenticator app", "2fa_hint": "Enter the 6-digit code from your authenticator app",
"2fa_invalid": "Enter a valid 6-digit code", "2fa_invalid": "Enter a valid 6-digit code",
"verify": "Verify" "verify": "Verify",
"remember_peer_password": "Mémoriser le mot de passe de lappareil sur cet ordinateur",
"save_peer_password": "Enregistrer le mot de passe"
}, },
"branding": { "branding": {
"identity_title": "Identité de marque", "identity_title": "Identité de marque",
@@ -3832,7 +3838,8 @@
"no_groups": "Aucun groupe", "no_groups": "Aucun groupe",
"no_tags": "Aucun tag", "no_tags": "Aucun tag",
"collapse_devices": "Réduire la liste des appareils", "collapse_devices": "Réduire la liste des appareils",
"expand_devices": "Développer la liste des appareils" "expand_devices": "Développer la liste des appareils",
"open_settings": "Paramètres"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Client Bureau à distance", "title": "Client Bureau à distance",
@@ -3852,5 +3859,15 @@
"network_error": "Erreur réseau", "network_error": "Erreur réseau",
"enter_6_digits": "Entrez les 6 chiffres", "enter_6_digits": "Entrez les 6 chiffres",
"invalid_code": "Code invalide" "invalid_code": "Code invalide"
},
"rdclient_settings": {
"title": "Paramètres RdClient",
"open": "Paramètres",
"server_url": "URL du panel",
"tls_strict": "TLS strict",
"sign_out": "Se déconnecter",
"reset_client": "Réinitialiser le client",
"discovery_refresh": "Actualiser",
"discovery_empty": "Aucun serveur trouvé sur le réseau local"
} }
} }
+20 -3
View File
@@ -744,7 +744,11 @@
"build_status_failed": "विफल", "build_status_failed": "विफल",
"builds_summary": "{{ready}} तैयार · {{pending}} कतार में · {{building}} बिल्ड हो रहा है · {{failed}} विफल", "builds_summary": "{{ready}} तैयार · {{pending}} कतार में · {{building}} बिल्ड हो रहा है · {{failed}} विफल",
"build_error_hint": "बिल्ड त्रुटि", "build_error_hint": "बिल्ड त्रुटि",
"preview_help": "सहायता का अनुरोध" "preview_help": "सहायता का अनुरोध",
"rdclient_tab": "RdClient डेस्कटॉप",
"rdclient_new_bundle": "नया RdClient बंडल",
"rdclient_subtitle": "एम्बेडेड पैनल URL के साथ RdClient इंस्टॉलर बनाएं",
"rdclient_server_url_hint": "BetterDesk कंसोल का सार्वजनिक URL (/remote)."
}, },
"server": { "server": {
"port": "Port", "port": "Port",
@@ -1050,7 +1054,9 @@
"file_loading": "लोड हो रहा है...", "file_loading": "लोड हो रहा है...",
"file_timeout": "रिमोट डिवाइस ने प्रतिक्रिया नहीं दी. फ़ाइल स्थानांतरण समर्थित नहीं हो सकता है या रिमोट मशीन पर अक्षम है।", "file_timeout": "रिमोट डिवाइस ने प्रतिक्रिया नहीं दी. फ़ाइल स्थानांतरण समर्थित नहीं हो सकता है या रिमोट मशीन पर अक्षम है।",
"codec": "वीडियो कोडेक", "codec": "वीडियो कोडेक",
"codec_auto": "ऑटो (अनुशंसित)" "codec_auto": "ऑटो (अनुशंसित)",
"remember_peer_password": "इस डिवाइस पर डिवाइस पासवर्ड याद रखें",
"save_peer_password": "पासवर्ड सहेजें"
}, },
"branding": { "branding": {
"identity_title": "ब्रांड पहचान", "identity_title": "ब्रांड पहचान",
@@ -3834,7 +3840,8 @@
"no_groups": "कोई समूह नहीं", "no_groups": "कोई समूह नहीं",
"no_tags": "कोई टैग नहीं", "no_tags": "कोई टैग नहीं",
"collapse_devices": "डिवाइस सूची संक्षिप्त करें", "collapse_devices": "डिवाइस सूची संक्षिप्त करें",
"expand_devices": "डिवाइस सूची विस्तृत करें" "expand_devices": "डिवाइस सूची विस्तृत करें",
"open_settings": "सेटिंग्स"
}, },
"rdclient_login": { "rdclient_login": {
"title": "रिमोट डेस्कटॉप क्लाइंट", "title": "रिमोट डेस्कटॉप क्लाइंट",
@@ -3854,5 +3861,15 @@
"network_error": "नेटवर्क त्रुटि", "network_error": "नेटवर्क त्रुटि",
"enter_6_digits": "सभी 6 अंक दर्ज करें", "enter_6_digits": "सभी 6 अंक दर्ज करें",
"invalid_code": "अमान्य कोड" "invalid_code": "अमान्य कोड"
},
"rdclient_settings": {
"title": "RdClient सेटिंग्स",
"open": "सेटिंग्स",
"server_url": "पैनल URL",
"tls_strict": "सख्त TLS",
"sign_out": "साइन आउट",
"reset_client": "क्लाइंट रीसेट करें",
"discovery_refresh": "रीफ़्रेश",
"discovery_empty": "स्थानीय नेटवर्क पर कोई सर्वर नहीं मिला"
} }
} }
+20 -3
View File
@@ -744,7 +744,11 @@
"rebuild_queued": "Alle Plattform-Builds in der Warteschlange", "rebuild_queued": "Alle Plattform-Builds in der Warteschlange",
"builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} sikertelen", "builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} sikertelen",
"build_error_hint": "Build hiba", "build_error_hint": "Build hiba",
"preview_help": "Segítség kérése" "preview_help": "Segítség kérése",
"rdclient_tab": "RdClient asztali",
"rdclient_new_bundle": "Új RdClient csomag",
"rdclient_subtitle": "RdClient telepítők készítése beágyazott panel URL-lel",
"rdclient_server_url_hint": "A BetterDesk konzol nyilvános URL-je (/remote)."
}, },
"server": { "server": {
"port": "Port", "port": "Port",
@@ -1050,7 +1054,9 @@
"file_loading": "Betöltés...", "file_loading": "Betöltés...",
"file_timeout": "A távoli eszköz nem válaszolt. Előfordulhat, hogy a fájlátvitel nem támogatott vagy le van tiltva a távoli gépen.", "file_timeout": "A távoli eszköz nem válaszolt. Előfordulhat, hogy a fájlátvitel nem támogatott vagy le van tiltva a távoli gépen.",
"codec": "Video Codec", "codec": "Video Codec",
"codec_auto": "Automatikus (ajánlott)" "codec_auto": "Automatikus (ajánlott)",
"remember_peer_password": "Eszköz jelszó megjegyzése ezen a gépen",
"save_peer_password": "Jelszó mentése"
}, },
"branding": { "branding": {
"identity_title": "Márkaidentitás", "identity_title": "Márkaidentitás",
@@ -3834,7 +3840,8 @@
"no_groups": "Nincs csoport", "no_groups": "Nincs csoport",
"no_tags": "Nincs címke", "no_tags": "Nincs címke",
"collapse_devices": "Eszközlista összecsukása", "collapse_devices": "Eszközlista összecsukása",
"expand_devices": "Eszközlista kibontása" "expand_devices": "Eszközlista kibontása",
"open_settings": "Beállítások"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Távoli asztal kliens", "title": "Távoli asztal kliens",
@@ -3854,5 +3861,15 @@
"network_error": "Hálózati hiba", "network_error": "Hálózati hiba",
"enter_6_digits": "Adja meg mind a 6 számjegyet", "enter_6_digits": "Adja meg mind a 6 számjegyet",
"invalid_code": "Érvénytelen kód" "invalid_code": "Érvénytelen kód"
},
"rdclient_settings": {
"title": "RdClient beállítások",
"open": "Beállítások",
"server_url": "Panel URL",
"tls_strict": "Szigorú TLS",
"sign_out": "Kijelentkezés",
"reset_client": "Kliens visszaállítása",
"discovery_refresh": "Frissítés",
"discovery_empty": "Nem található szerver a helyi hálózaton"
} }
} }
+20 -3
View File
@@ -744,7 +744,11 @@
"build_status_failed": "Gagal", "build_status_failed": "Gagal",
"builds_summary": "{{ready}} siap · {{pending}} antrian · {{building}} membangun · {{failed}} gagal", "builds_summary": "{{ready}} siap · {{pending}} antrian · {{building}} membangun · {{failed}} gagal",
"build_error_hint": "Kesalahan build", "build_error_hint": "Kesalahan build",
"preview_help": "Minta bantuan" "preview_help": "Minta bantuan",
"rdclient_tab": "RdClient desktop",
"rdclient_new_bundle": "Paket RdClient baru",
"rdclient_subtitle": "Buat installer RdClient dengan URL panel tertanam",
"rdclient_server_url_hint": "URL publik konsol BetterDesk (/remote)."
}, },
"server": { "server": {
"port": "Port", "port": "Port",
@@ -1050,7 +1054,9 @@
"file_loading": "Memuat...", "file_loading": "Memuat...",
"file_timeout": "Perangkat jarak jauh tidak merespons. Transfer file mungkin tidak didukung atau dinonaktifkan pada mesin jarak jauh.", "file_timeout": "Perangkat jarak jauh tidak merespons. Transfer file mungkin tidak didukung atau dinonaktifkan pada mesin jarak jauh.",
"codec": "Kodek Video", "codec": "Kodek Video",
"codec_auto": "Otomatis (disarankan)" "codec_auto": "Otomatis (disarankan)",
"remember_peer_password": "Ingat kata sandi perangkat di komputer ini",
"save_peer_password": "Simpan kata sandi"
}, },
"branding": { "branding": {
"identity_title": "Identitas Merek", "identity_title": "Identitas Merek",
@@ -3834,7 +3840,8 @@
"no_groups": "Tidak ada grup", "no_groups": "Tidak ada grup",
"no_tags": "Tidak ada tag", "no_tags": "Tidak ada tag",
"collapse_devices": "Ciutkan daftar perangkat", "collapse_devices": "Ciutkan daftar perangkat",
"expand_devices": "Perluas daftar perangkat" "expand_devices": "Perluas daftar perangkat",
"open_settings": "Pengaturan"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Klien Desktop Jarak Jauh", "title": "Klien Desktop Jarak Jauh",
@@ -3854,5 +3861,15 @@
"network_error": "Kesalahan jaringan", "network_error": "Kesalahan jaringan",
"enter_6_digits": "Masukkan semua 6 digit", "enter_6_digits": "Masukkan semua 6 digit",
"invalid_code": "Kode tidak valid" "invalid_code": "Kode tidak valid"
},
"rdclient_settings": {
"title": "Pengaturan RdClient",
"open": "Pengaturan",
"server_url": "URL panel",
"tls_strict": "TLS ketat",
"sign_out": "Keluar",
"reset_client": "Reset klien",
"discovery_refresh": "Segarkan",
"discovery_empty": "Tidak ada server di jaringan lokal"
} }
} }
+20 -3
View File
@@ -738,7 +738,11 @@
"build_status_failed": "Èchouè", "build_status_failed": "Èchouè",
"builds_summary": "{{ready}} prèt · {{pending}} en file d'attesa · {{building}} en compilation · {{failed}} èchouè", "builds_summary": "{{ready}} prèt · {{pending}} en file d'attesa · {{building}} en compilation · {{failed}} èchouè",
"build_error_hint": "Erreur de compilation", "build_error_hint": "Erreur de compilation",
"preview_help": "Richiedi aiuto" "preview_help": "Richiedi aiuto",
"rdclient_tab": "RdClient desktop",
"rdclient_new_bundle": "Nuovo bundle RdClient",
"rdclient_subtitle": "Crea installer RdClient con URL pannello incorporato",
"rdclient_server_url_hint": "URL pubblica della console BetterDesk (/remote)."
}, },
"server": { "server": {
"port": "Porta", "port": "Porta",
@@ -1044,7 +1048,9 @@
"file_loading": "Caricamento...", "file_loading": "Caricamento...",
"file_timeout": "Il dispositivo remoto non ha risposto in tempo. Il trasferimento file potrebbe non essere supportato o potrebbe essere disabilitato.", "file_timeout": "Il dispositivo remoto non ha risposto in tempo. Il trasferimento file potrebbe non essere supportato o potrebbe essere disabilitato.",
"codec": "Codec video", "codec": "Codec video",
"codec_auto": "Automatico (consigliato)" "codec_auto": "Automatico (consigliato)",
"remember_peer_password": "Ricorda password dispositivo su questo computer",
"save_peer_password": "Salva password"
}, },
"branding": { "branding": {
"identity_title": "Identità del marchio", "identity_title": "Identità del marchio",
@@ -3828,7 +3834,8 @@
"no_groups": "Nessun gruppo", "no_groups": "Nessun gruppo",
"no_tags": "Nessun tag", "no_tags": "Nessun tag",
"collapse_devices": "Comprimi elenco dispositivi", "collapse_devices": "Comprimi elenco dispositivi",
"expand_devices": "Espandi elenco dispositivi" "expand_devices": "Espandi elenco dispositivi",
"open_settings": "Impostazioni"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Client desktop remoto", "title": "Client desktop remoto",
@@ -3848,5 +3855,15 @@
"network_error": "Errore di rete", "network_error": "Errore di rete",
"enter_6_digits": "Inserisci tutte le 6 cifre", "enter_6_digits": "Inserisci tutte le 6 cifre",
"invalid_code": "Codice non valido" "invalid_code": "Codice non valido"
},
"rdclient_settings": {
"title": "Impostazioni RdClient",
"open": "Impostazioni",
"server_url": "URL pannello",
"tls_strict": "TLS rigoroso",
"sign_out": "Esci",
"reset_client": "Reimposta client",
"discovery_refresh": "Aggiorna",
"discovery_empty": "Nessun server trovato nella rete locale"
} }
} }
+20 -3
View File
@@ -738,7 +738,11 @@
"build_status_failed": "失敗", "build_status_failed": "失敗",
"builds_summary": "{{ready}} 準備完了 · {{pending}} キュー中 · {{building}} ビルド中 · {{failed}} 失敗", "builds_summary": "{{ready}} 準備完了 · {{pending}} キュー中 · {{building}} ビルド中 · {{failed}} 失敗",
"build_error_hint": "ビルドエラー", "build_error_hint": "ビルドエラー",
"preview_help": "ヘルプを依頼" "preview_help": "ヘルプを依頼",
"rdclient_tab": "RdClient デスクトップ",
"rdclient_new_bundle": "新規 RdClient バンドル",
"rdclient_subtitle": "パネル URL を埋め込んだ RdClient インストーラーをビルド",
"rdclient_server_url_hint": "BetterDesk コンソールの公開 URL (/remote)。"
}, },
"server": { "server": {
"port": "Port", "port": "Port",
@@ -1044,7 +1048,9 @@
"file_loading": "読み込み中...", "file_loading": "読み込み中...",
"file_timeout": "リモートデバイスが応答しませんでした。ファイル転送がサポートされていないか、リモート マシンで無効になっている可能性があります。", "file_timeout": "リモートデバイスが応答しませんでした。ファイル転送がサポートされていないか、リモート マシンで無効になっている可能性があります。",
"codec": "ビデオコーデック", "codec": "ビデオコーデック",
"codec_auto": "自動 (推奨)" "codec_auto": "自動 (推奨)",
"remember_peer_password": "このデバイスでデバイスパスワードを記憶",
"save_peer_password": "パスワードを保存"
}, },
"branding": { "branding": {
"identity_title": "ブランドアイデンティティ", "identity_title": "ブランドアイデンティティ",
@@ -3828,7 +3834,8 @@
"no_groups": "グループなし", "no_groups": "グループなし",
"no_tags": "タグなし", "no_tags": "タグなし",
"collapse_devices": "デバイス一覧を折りたたむ", "collapse_devices": "デバイス一覧を折りたたむ",
"expand_devices": "デバイス一覧を展開" "expand_devices": "デバイス一覧を展開",
"open_settings": "設定"
}, },
"rdclient_login": { "rdclient_login": {
"title": "リモートデスクトップクライアント", "title": "リモートデスクトップクライアント",
@@ -3848,5 +3855,15 @@
"network_error": "ネットワークエラー", "network_error": "ネットワークエラー",
"enter_6_digits": "6桁すべてを入力してください", "enter_6_digits": "6桁すべてを入力してください",
"invalid_code": "無効なコード" "invalid_code": "無効なコード"
},
"rdclient_settings": {
"title": "RdClient 設定",
"open": "設定",
"server_url": "パネル URL",
"tls_strict": "厳格 TLS",
"sign_out": "サインアウト",
"reset_client": "クライアントをリセット",
"discovery_refresh": "更新",
"discovery_empty": "ローカルネットワークにサーバーが見つかりません"
} }
} }
+20 -3
View File
@@ -738,7 +738,11 @@
"build_status_failed": "실패", "build_status_failed": "실패",
"builds_summary": "{{ready}} 준비 완료 · {{pending}} 대기 중 · {{building}} 빌드 중 · {{failed}} 실패", "builds_summary": "{{ready}} 준비 완료 · {{pending}} 대기 중 · {{building}} 빌드 중 · {{failed}} 실패",
"build_error_hint": "빌드 오류", "build_error_hint": "빌드 오류",
"preview_help": "도움 요청" "preview_help": "도움 요청",
"rdclient_tab": "RdClient 데스크톱",
"rdclient_new_bundle": "새 RdClient 번들",
"rdclient_subtitle": "패널 URL이 포함된 RdClient 설치 프로그램 빌드",
"rdclient_server_url_hint": "BetterDesk 콘솔 공개 URL (/remote)."
}, },
"server": { "server": {
"port": "Port", "port": "Port",
@@ -1044,7 +1048,9 @@
"file_loading": "로드 중...", "file_loading": "로드 중...",
"file_timeout": "원격 장치가 응답하지 않았습니다. 원격 시스템에서 파일 전송이 지원되지 않거나 비활성화되어 있을 수 있습니다.", "file_timeout": "원격 장치가 응답하지 않았습니다. 원격 시스템에서 파일 전송이 지원되지 않거나 비활성화되어 있을 수 있습니다.",
"codec": "비디오 코덱", "codec": "비디오 코덱",
"codec_auto": "자동(권장)" "codec_auto": "자동(권장)",
"remember_peer_password": "이 기기에서 장치 비밀번호 기억",
"save_peer_password": "비밀번호 저장"
}, },
"branding": { "branding": {
"identity_title": "브랜드 아이덴티티", "identity_title": "브랜드 아이덴티티",
@@ -3828,7 +3834,8 @@
"no_groups": "그룹 없음", "no_groups": "그룹 없음",
"no_tags": "태그 없음", "no_tags": "태그 없음",
"collapse_devices": "장치 목록 접기", "collapse_devices": "장치 목록 접기",
"expand_devices": "장치 목록 펼치기" "expand_devices": "장치 목록 펼치기",
"open_settings": "설정"
}, },
"rdclient_login": { "rdclient_login": {
"title": "원격 데스크톱 클라이언트", "title": "원격 데스크톱 클라이언트",
@@ -3848,5 +3855,15 @@
"network_error": "네트워크 오류", "network_error": "네트워크 오류",
"enter_6_digits": "6자리 모두 입력하세요", "enter_6_digits": "6자리 모두 입력하세요",
"invalid_code": "잘못된 코드" "invalid_code": "잘못된 코드"
},
"rdclient_settings": {
"title": "RdClient 설정",
"open": "설정",
"server_url": "패널 URL",
"tls_strict": "엄격 TLS",
"sign_out": "로그아웃",
"reset_client": "클라이언트 재설정",
"discovery_refresh": "새로고침",
"discovery_empty": "로컬 네트워크에서 서버를 찾을 수 없습니다"
} }
} }
+20 -3
View File
@@ -744,7 +744,11 @@
"rebuild_queued": "Alle Plattform-Builds in der Warteschlange", "rebuild_queued": "Alle Plattform-Builds in der Warteschlange",
"builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} mislyktes", "builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} mislyktes",
"build_error_hint": "Byggfeil", "build_error_hint": "Byggfeil",
"preview_help": "Be om hjelp" "preview_help": "Be om hjelp",
"rdclient_tab": "RdClient desktop",
"rdclient_new_bundle": "Nytt RdClient-pakke",
"rdclient_subtitle": "Bygg RdClient-installere med innebygd panel-URL",
"rdclient_server_url_hint": "Offentlig URL til BetterDesk-konsollen (/remote)."
}, },
"server": { "server": {
"port": "Port", "port": "Port",
@@ -1050,7 +1054,9 @@
"file_loading": "Laster inn...", "file_loading": "Laster inn...",
"file_timeout": "Ekstern enhet svarte ikke. Filoverføring støttes kanskje ikke eller er deaktivert på den eksterne maskinen.", "file_timeout": "Ekstern enhet svarte ikke. Filoverføring støttes kanskje ikke eller er deaktivert på den eksterne maskinen.",
"codec": "Videokodek", "codec": "Videokodek",
"codec_auto": "Auto (anbefalt)" "codec_auto": "Auto (anbefalt)",
"remember_peer_password": "Husk enhetspassord på denne datamaskinen",
"save_peer_password": "Lagre passord"
}, },
"branding": { "branding": {
"identity_title": "Merkeidentitet", "identity_title": "Merkeidentitet",
@@ -3834,7 +3840,8 @@
"no_groups": "Ingen grupper", "no_groups": "Ingen grupper",
"no_tags": "Ingen tagger", "no_tags": "Ingen tagger",
"collapse_devices": "Skjul enhetsliste", "collapse_devices": "Skjul enhetsliste",
"expand_devices": "Vis enhetsliste" "expand_devices": "Vis enhetsliste",
"open_settings": "Innstillinger"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Remote Desktop-klient", "title": "Remote Desktop-klient",
@@ -3854,5 +3861,15 @@
"network_error": "Nettverksfeil", "network_error": "Nettverksfeil",
"enter_6_digits": "Skriv inn alle 6 sifre", "enter_6_digits": "Skriv inn alle 6 sifre",
"invalid_code": "Ugyldig kode" "invalid_code": "Ugyldig kode"
},
"rdclient_settings": {
"title": "RdClient-innstillinger",
"open": "Innstillinger",
"server_url": "Panel-URL",
"tls_strict": "Streng TLS",
"sign_out": "Logg ut",
"reset_client": "Tilbakestill klient",
"discovery_refresh": "Oppdater",
"discovery_empty": "Ingen servere funnet på det lokale nettverket"
} }
} }
+20 -3
View File
@@ -738,7 +738,11 @@
"build_status_failed": "Mislukt", "build_status_failed": "Mislukt",
"builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} mislukt", "builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} mislukt",
"build_error_hint": "Build-Fout", "build_error_hint": "Build-Fout",
"preview_help": "Hulp vragen" "preview_help": "Hulp vragen",
"rdclient_tab": "RdClient desktop",
"rdclient_new_bundle": "Nieuw RdClient-pakket",
"rdclient_subtitle": "RdClient-installers bouwen met ingebedde paneel-URL",
"rdclient_server_url_hint": "Publieke URL van het BetterDesk-paneel (/remote)."
}, },
"server": { "server": {
"port": "Poort", "port": "Poort",
@@ -1044,7 +1048,9 @@
"file_loading": "Laden...", "file_loading": "Laden...",
"file_timeout": "Het externe apparaat reageerde niet. Bestandsoverdracht wordt mogelijk niet ondersteund of is uitgeschakeld op de externe computer.", "file_timeout": "Het externe apparaat reageerde niet. Bestandsoverdracht wordt mogelijk niet ondersteund of is uitgeschakeld op de externe computer.",
"codec": "Videocodec", "codec": "Videocodec",
"codec_auto": "Automatisch (aanbevolen)" "codec_auto": "Automatisch (aanbevolen)",
"remember_peer_password": "Apparaatwachtwoord onthouden op dit apparaat",
"save_peer_password": "Wachtwoord opslaan"
}, },
"branding": { "branding": {
"identity_title": "Merkidentiteit", "identity_title": "Merkidentiteit",
@@ -3828,7 +3834,8 @@
"no_groups": "Geen groepen", "no_groups": "Geen groepen",
"no_tags": "Geen tags", "no_tags": "Geen tags",
"collapse_devices": "Apparaatlijst inklappen", "collapse_devices": "Apparaatlijst inklappen",
"expand_devices": "Apparaatlijst uitklappen" "expand_devices": "Apparaatlijst uitklappen",
"open_settings": "Instellingen"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Remote Desktop-client", "title": "Remote Desktop-client",
@@ -3848,5 +3855,15 @@
"network_error": "Netwerkfout", "network_error": "Netwerkfout",
"enter_6_digits": "Voer alle 6 cijfers in", "enter_6_digits": "Voer alle 6 cijfers in",
"invalid_code": "Ongeldige code" "invalid_code": "Ongeldige code"
},
"rdclient_settings": {
"title": "RdClient-instellingen",
"open": "Instellingen",
"server_url": "Paneel-URL",
"tls_strict": "Strikte TLS",
"sign_out": "Afmelden",
"reset_client": "Client resetten",
"discovery_refresh": "Vernieuwen",
"discovery_empty": "Geen servers gevonden op het lokale netwerk"
} }
} }
+20 -3
View File
@@ -738,7 +738,11 @@
"generated": "Konfiguracja wygenerowana", "generated": "Konfiguracja wygenerowana",
"server_required": "Adres serwera jest wymagany", "server_required": "Adres serwera jest wymagany",
"nothing_to_copy": "Najpierw wygeneruj konfigurację", "nothing_to_copy": "Najpierw wygeneruj konfigurację",
"preview_help": "Poproś o pomoc" "preview_help": "Poproś o pomoc",
"rdclient_tab": "RdClient desktop",
"rdclient_new_bundle": "Nowy pakiet RdClient",
"rdclient_subtitle": "Buduj instalatory RdClient z wbudowanym adresem panelu",
"rdclient_server_url_hint": "Publiczny URL panelu BetterDesk (dashboard /remote)."
}, },
"download": { "download": {
"title": "Pobierz Agenta BetterDesk", "title": "Pobierz Agenta BetterDesk",
@@ -1054,7 +1058,9 @@
"2fa_required": "Wymagana weryfikacja 2FA", "2fa_required": "Wymagana weryfikacja 2FA",
"2fa_hint": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej", "2fa_hint": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej",
"2fa_invalid": "Wprowadź prawidłowy 6-cyfrowy kod", "2fa_invalid": "Wprowadź prawidłowy 6-cyfrowy kod",
"verify": "Weryfikuj" "verify": "Weryfikuj",
"remember_peer_password": "Zapamiętaj hasło urządzenia na tym komputerze",
"save_peer_password": "Zapisz hasło"
}, },
"branding": { "branding": {
"identity_title": "Identyfikacja marki", "identity_title": "Identyfikacja marki",
@@ -3828,7 +3834,8 @@
"no_groups": "Brak grup", "no_groups": "Brak grup",
"no_tags": "Brak tagów", "no_tags": "Brak tagów",
"collapse_devices": "Zwiń listę urządzeń", "collapse_devices": "Zwiń listę urządzeń",
"expand_devices": "Rozwiń listę urządzeń" "expand_devices": "Rozwiń listę urządzeń",
"open_settings": "Ustawienia"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Klient pulpitu zdalnego", "title": "Klient pulpitu zdalnego",
@@ -3848,5 +3855,15 @@
"network_error": "Błąd sieci", "network_error": "Błąd sieci",
"enter_6_digits": "Wprowadź wszystkie 6 cyfr", "enter_6_digits": "Wprowadź wszystkie 6 cyfr",
"invalid_code": "Nieprawidłowy kod" "invalid_code": "Nieprawidłowy kod"
},
"rdclient_settings": {
"title": "Ustawienia RdClient",
"open": "Ustawienia",
"server_url": "URL panelu",
"tls_strict": "Ścisłe TLS",
"sign_out": "Wyloguj",
"reset_client": "Resetuj klienta",
"discovery_refresh": "Odśwież",
"discovery_empty": "Nie znaleziono serwerów w sieci lokalnej"
} }
} }
+20 -3
View File
@@ -738,7 +738,11 @@
"build_status_failed": "Falhou", "build_status_failed": "Falhou",
"builds_summary": "{{ready}} pronto · {{pending}} en fila · {{building}} compilando · {{failed}} falhou", "builds_summary": "{{ready}} pronto · {{pending}} en fila · {{building}} compilando · {{failed}} falhou",
"build_error_hint": "Erro de compilação", "build_error_hint": "Erro de compilação",
"preview_help": "Solicitar ajuda" "preview_help": "Solicitar ajuda",
"rdclient_tab": "RdClient desktop",
"rdclient_new_bundle": "Novo pacote RdClient",
"rdclient_subtitle": "Criar instaladores RdClient com URL do painel incorporado",
"rdclient_server_url_hint": "URL pública da consola BetterDesk (/remote)."
}, },
"server": { "server": {
"port": "Porta", "port": "Porta",
@@ -1044,7 +1048,9 @@
"file_loading": "Carregando...", "file_loading": "Carregando...",
"file_timeout": "O dispositivo remoto não respondeu. A transferência de arquivos pode não ser compatível ou pode estar desativada no computador remoto.", "file_timeout": "O dispositivo remoto não respondeu. A transferência de arquivos pode não ser compatível ou pode estar desativada no computador remoto.",
"codec": "Codec de vídeo", "codec": "Codec de vídeo",
"codec_auto": "Automático (recomendado)" "codec_auto": "Automático (recomendado)",
"remember_peer_password": "Lembrar senha do dispositivo neste computador",
"save_peer_password": "Guardar senha"
}, },
"branding": { "branding": {
"identity_title": "Identidade da marca", "identity_title": "Identidade da marca",
@@ -3828,7 +3834,8 @@
"no_groups": "Sem grupos", "no_groups": "Sem grupos",
"no_tags": "Sem etiquetas", "no_tags": "Sem etiquetas",
"collapse_devices": "Recolher lista de dispositivos", "collapse_devices": "Recolher lista de dispositivos",
"expand_devices": "Expandir lista de dispositivos" "expand_devices": "Expandir lista de dispositivos",
"open_settings": "Definições"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Cliente de ambiente de trabalho remoto", "title": "Cliente de ambiente de trabalho remoto",
@@ -3848,5 +3855,15 @@
"network_error": "Erro de rede", "network_error": "Erro de rede",
"enter_6_digits": "Introduza os 6 dígitos", "enter_6_digits": "Introduza os 6 dígitos",
"invalid_code": "Código inválido" "invalid_code": "Código inválido"
},
"rdclient_settings": {
"title": "Definições RdClient",
"open": "Definições",
"server_url": "URL do painel",
"tls_strict": "TLS estrito",
"sign_out": "Terminar sessão",
"reset_client": "Repor cliente",
"discovery_refresh": "Atualizar",
"discovery_empty": "Nenhum servidor encontrado na rede local"
} }
} }
+20 -3
View File
@@ -744,7 +744,11 @@
"rebuild_queued": "Alle Plattform-Builds in der Warteschlange", "rebuild_queued": "Alle Plattform-Builds in der Warteschlange",
"builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} eșuat", "builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} eșuat",
"build_error_hint": "Eroare build", "build_error_hint": "Eroare build",
"preview_help": "Solicită ajutor" "preview_help": "Solicită ajutor",
"rdclient_tab": "RdClient desktop",
"rdclient_new_bundle": "Pachet RdClient nou",
"rdclient_subtitle": "Construiește instalatoare RdClient cu URL panou integrat",
"rdclient_server_url_hint": "URL public al consolei BetterDesk (/remote)."
}, },
"server": { "server": {
"port": "Port", "port": "Port",
@@ -1050,7 +1054,9 @@
"file_loading": "Se încarcă...", "file_loading": "Se încarcă...",
"file_timeout": "Dispozitivul de la distanță nu a răspuns. Este posibil ca transferul de fișiere să nu fie acceptat sau să fie dezactivat pe computerul de la distanță.", "file_timeout": "Dispozitivul de la distanță nu a răspuns. Este posibil ca transferul de fișiere să nu fie acceptat sau să fie dezactivat pe computerul de la distanță.",
"codec": "Codec video", "codec": "Codec video",
"codec_auto": "Auto (recomandat)" "codec_auto": "Auto (recomandat)",
"remember_peer_password": "Memorează parola dispozitivului pe acest computer",
"save_peer_password": "Salvează parola"
}, },
"branding": { "branding": {
"identity_title": "Identitatea de marcă", "identity_title": "Identitatea de marcă",
@@ -3834,7 +3840,8 @@
"no_groups": "Fără grupuri", "no_groups": "Fără grupuri",
"no_tags": "Fără etichete", "no_tags": "Fără etichete",
"collapse_devices": "Restrânge lista de dispozitive", "collapse_devices": "Restrânge lista de dispozitive",
"expand_devices": "Extinde lista de dispozitive" "expand_devices": "Extinde lista de dispozitive",
"open_settings": "Setări"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Client desktop la distanță", "title": "Client desktop la distanță",
@@ -3854,5 +3861,15 @@
"network_error": "Eroare de rețea", "network_error": "Eroare de rețea",
"enter_6_digits": "Introduceți toate cele 6 cifre", "enter_6_digits": "Introduceți toate cele 6 cifre",
"invalid_code": "Cod invalid" "invalid_code": "Cod invalid"
},
"rdclient_settings": {
"title": "Setări RdClient",
"open": "Setări",
"server_url": "URL panou",
"tls_strict": "TLS strict",
"sign_out": "Deconectare",
"reset_client": "Resetează clientul",
"discovery_refresh": "Reîmprospătează",
"discovery_empty": "Nu s-au găsit servere în rețeaua locală"
} }
} }
+20 -3
View File
@@ -744,7 +744,11 @@
"rebuild_queued": "Alle Plattform-Builds in der Warteschlange", "rebuild_queued": "Alle Plattform-Builds in der Warteschlange",
"builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} misslyckades", "builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} misslyckades",
"build_error_hint": "Byggfel", "build_error_hint": "Byggfel",
"preview_help": "Begär hjälp" "preview_help": "Begär hjälp",
"rdclient_tab": "RdClient desktop",
"rdclient_new_bundle": "Nytt RdClient-paket",
"rdclient_subtitle": "Bygg RdClient-installerare med inbäddad panel-URL",
"rdclient_server_url_hint": "Offentlig URL till BetterDesk-konsolen (/remote)."
}, },
"server": { "server": {
"port": "Port", "port": "Port",
@@ -1050,7 +1054,9 @@
"file_loading": "Laddar...", "file_loading": "Laddar...",
"file_timeout": "Fjärrenheten svarade inte. Filöverföring kanske inte stöds eller är inaktiverad på fjärrmaskinen.", "file_timeout": "Fjärrenheten svarade inte. Filöverföring kanske inte stöds eller är inaktiverad på fjärrmaskinen.",
"codec": "Video Codec", "codec": "Video Codec",
"codec_auto": "Auto (rekommenderas)" "codec_auto": "Auto (rekommenderas)",
"remember_peer_password": "Kom ihåg enhetslösenord på den här datorn",
"save_peer_password": "Spara lösenord"
}, },
"branding": { "branding": {
"identity_title": "Varumärkesidentitet", "identity_title": "Varumärkesidentitet",
@@ -3834,7 +3840,8 @@
"no_groups": "Inga grupper", "no_groups": "Inga grupper",
"no_tags": "Inga taggar", "no_tags": "Inga taggar",
"collapse_devices": "Fäll ihop enhetslista", "collapse_devices": "Fäll ihop enhetslista",
"expand_devices": "Expandera enhetslista" "expand_devices": "Expandera enhetslista",
"open_settings": "Inställningar"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Remote Desktop-klient", "title": "Remote Desktop-klient",
@@ -3854,5 +3861,15 @@
"network_error": "Nätverksfel", "network_error": "Nätverksfel",
"enter_6_digits": "Ange alla 6 siffror", "enter_6_digits": "Ange alla 6 siffror",
"invalid_code": "Ogiltig kod" "invalid_code": "Ogiltig kod"
},
"rdclient_settings": {
"title": "RdClient-inställningar",
"open": "Inställningar",
"server_url": "Panel-URL",
"tls_strict": "Strikt TLS",
"sign_out": "Logga ut",
"reset_client": "Återställ klient",
"discovery_refresh": "Uppdatera",
"discovery_empty": "Inga servrar hittades i det lokala nätverket"
} }
} }
+20 -3
View File
@@ -744,7 +744,11 @@
"build_status_failed": "ล้มเหลว", "build_status_failed": "ล้มเหลว",
"builds_summary": "{{ready}} พร้อม · {{pending}} รอคิว · {{building}} กำลัง Build · {{failed}} ล้มเหลว", "builds_summary": "{{ready}} พร้อม · {{pending}} รอคิว · {{building}} กำลัง Build · {{failed}} ล้มเหลว",
"build_error_hint": "ข้อผิดพลาด Build", "build_error_hint": "ข้อผิดพลาด Build",
"preview_help": "ขอความช่วยเหลือ" "preview_help": "ขอความช่วยเหลือ",
"rdclient_tab": "RdClient เดสก์ท็อป",
"rdclient_new_bundle": "ชุด RdClient ใหม่",
"rdclient_subtitle": "สร้างตัวติดตั้ง RdClient พร้อม URL แผงควบคุมฝังตัว",
"rdclient_server_url_hint": "URL สาธารณะของคอนโซล BetterDesk (/remote)"
}, },
"server": { "server": {
"port": "Port", "port": "Port",
@@ -1050,7 +1054,9 @@
"file_loading": "กำลังโหลด...", "file_loading": "กำลังโหลด...",
"file_timeout": "อุปกรณ์ระยะไกลไม่ตอบสนอง การถ่ายโอนไฟล์อาจไม่รองรับหรือถูกปิดใช้งานบนเครื่องระยะไกล", "file_timeout": "อุปกรณ์ระยะไกลไม่ตอบสนอง การถ่ายโอนไฟล์อาจไม่รองรับหรือถูกปิดใช้งานบนเครื่องระยะไกล",
"codec": "ตัวแปลงสัญญาณวิดีโอ", "codec": "ตัวแปลงสัญญาณวิดีโอ",
"codec_auto": "อัตโนมัติ (แนะนำ)" "codec_auto": "อัตโนมัติ (แนะนำ)",
"remember_peer_password": "จดจำรหัสผ่านอุปกรณ์บนคอมพิวเตอร์นี้",
"save_peer_password": "บันทึกรหัสผ่าน"
}, },
"branding": { "branding": {
"identity_title": "เอกลักษณ์ของแบรนด์", "identity_title": "เอกลักษณ์ของแบรนด์",
@@ -3834,7 +3840,8 @@
"no_groups": "ไม่มีกลุ่ม", "no_groups": "ไม่มีกลุ่ม",
"no_tags": "ไม่มีแท็ก", "no_tags": "ไม่มีแท็ก",
"collapse_devices": "ยุบรายการอุปกรณ์", "collapse_devices": "ยุบรายการอุปกรณ์",
"expand_devices": "ขยายรายการอุปกรณ์" "expand_devices": "ขยายรายการอุปกรณ์",
"open_settings": "การตั้งค่า"
}, },
"rdclient_login": { "rdclient_login": {
"title": "ไคลเอนต์เดสก์ท็อประยะไกล", "title": "ไคลเอนต์เดสก์ท็อประยะไกล",
@@ -3854,5 +3861,15 @@
"network_error": "ข้อผิดพลาดเครือข่าย", "network_error": "ข้อผิดพลาดเครือข่าย",
"enter_6_digits": "ป้อนตัวเลขครบ 6 หลัก", "enter_6_digits": "ป้อนตัวเลขครบ 6 หลัก",
"invalid_code": "รหัสไม่ถูกต้อง" "invalid_code": "รหัสไม่ถูกต้อง"
},
"rdclient_settings": {
"title": "การตั้งค่า RdClient",
"open": "การตั้งค่า",
"server_url": "URL แผงควบคุม",
"tls_strict": "TLS เข้มงวด",
"sign_out": "ออกจากระบบ",
"reset_client": "รีเซ็ตไคลเอนต์",
"discovery_refresh": "รีเฟรช",
"discovery_empty": "ไม่พบเซิร์ฟเวอร์ในเครือข่ายท้องถิ่น"
} }
} }
+20 -3
View File
@@ -744,7 +744,11 @@
"build_status_failed": "Başarısız", "build_status_failed": "Başarısız",
"builds_summary": "{{ready}} hazır · {{pending}} sırada · {{building}} derleniyor · {{failed}} başarısız", "builds_summary": "{{ready}} hazır · {{pending}} sırada · {{building}} derleniyor · {{failed}} başarısız",
"build_error_hint": "Derleme hatası", "build_error_hint": "Derleme hatası",
"preview_help": "Yardım iste" "preview_help": "Yardım iste",
"rdclient_tab": "RdClient masaüstü",
"rdclient_new_bundle": "Yeni RdClient paketi",
"rdclient_subtitle": "Gömülü panel URLli RdClient yükleyicileri oluştur",
"rdclient_server_url_hint": "BetterDesk konsolunun genel URLsi (/remote)."
}, },
"server": { "server": {
"port": "Port", "port": "Port",
@@ -1050,7 +1054,9 @@
"file_loading": "Yükleniyor...", "file_loading": "Yükleniyor...",
"file_timeout": "Uzak aygıt yanıt vermedi. Uzak makinede dosya aktarımı desteklenmiyor veya devre dışı bırakılmış olabilir.", "file_timeout": "Uzak aygıt yanıt vermedi. Uzak makinede dosya aktarımı desteklenmiyor veya devre dışı bırakılmış olabilir.",
"codec": "Video Codec'i", "codec": "Video Codec'i",
"codec_auto": "Otomatik (önerilen)" "codec_auto": "Otomatik (önerilen)",
"remember_peer_password": "Cihaz parolasını bu bilgisayarda hatırla",
"save_peer_password": "Parolayı kaydet"
}, },
"branding": { "branding": {
"identity_title": "Marka Kimliği", "identity_title": "Marka Kimliği",
@@ -3834,7 +3840,8 @@
"no_groups": "Grup yok", "no_groups": "Grup yok",
"no_tags": "Etiket yok", "no_tags": "Etiket yok",
"collapse_devices": "Cihaz listesini daralt", "collapse_devices": "Cihaz listesini daralt",
"expand_devices": "Cihaz listesini genişlet" "expand_devices": "Cihaz listesini genişlet",
"open_settings": "Ayarlar"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Uzak Masaüstü İstemcisi", "title": "Uzak Masaüstü İstemcisi",
@@ -3854,5 +3861,15 @@
"network_error": "Ağ hatası", "network_error": "Ağ hatası",
"enter_6_digits": "6 hanenin tamamını girin", "enter_6_digits": "6 hanenin tamamını girin",
"invalid_code": "Geçersiz kod" "invalid_code": "Geçersiz kod"
},
"rdclient_settings": {
"title": "RdClient ayarları",
"open": "Ayarlar",
"server_url": "Panel URL",
"tls_strict": "Katı TLS",
"sign_out": "Oturumu kapat",
"reset_client": "İstemciyi sıfırla",
"discovery_refresh": "Yenile",
"discovery_empty": "Yerel ağda sunucu bulunamadı"
} }
} }
+20 -3
View File
@@ -744,7 +744,11 @@
"rebuild_queued": "Alle Plattform-Builds in der Warteschlange", "rebuild_queued": "Alle Plattform-Builds in der Warteschlange",
"builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} не вдалося", "builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} не вдалося",
"build_error_hint": "Помилка збірки", "build_error_hint": "Помилка збірки",
"preview_help": "Запросити допомогу" "preview_help": "Запросити допомогу",
"rdclient_tab": "RdClient desktop",
"rdclient_new_bundle": "Новий пакет RdClient",
"rdclient_subtitle": "Збирати інсталятори RdClient із вбудованою URL панелі",
"rdclient_server_url_hint": "Публічна URL консолі BetterDesk (/remote)."
}, },
"server": { "server": {
"port": "Port", "port": "Port",
@@ -1050,7 +1054,9 @@
"file_loading": "Завантаження...", "file_loading": "Завантаження...",
"file_timeout": "Віддалений пристрій не відповідає. Передача файлів може не підтримуватися або вимкнена на віддаленій машині.", "file_timeout": "Віддалений пристрій не відповідає. Передача файлів може не підтримуватися або вимкнена на віддаленій машині.",
"codec": "Відеокодек", "codec": "Відеокодек",
"codec_auto": "Авто (рекомендовано)" "codec_auto": "Авто (рекомендовано)",
"remember_peer_password": "Запам’ятати пароль пристрою на цьому комп’ютері",
"save_peer_password": "Зберегти пароль"
}, },
"branding": { "branding": {
"identity_title": "Ідентичність бренду", "identity_title": "Ідентичність бренду",
@@ -3834,7 +3840,8 @@
"no_groups": "Немає груп", "no_groups": "Немає груп",
"no_tags": "Немає тегів", "no_tags": "Немає тегів",
"collapse_devices": "Згорнути список пристроїв", "collapse_devices": "Згорнути список пристроїв",
"expand_devices": "Розгорнути список пристроїв" "expand_devices": "Розгорнути список пристроїв",
"open_settings": "Налаштування"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Клієнт віддаленого робочого столу", "title": "Клієнт віддаленого робочого столу",
@@ -3854,5 +3861,15 @@
"network_error": "Помилка мережі", "network_error": "Помилка мережі",
"enter_6_digits": "Введіть усі 6 цифр", "enter_6_digits": "Введіть усі 6 цифр",
"invalid_code": "Невірний код" "invalid_code": "Невірний код"
},
"rdclient_settings": {
"title": "Налаштування RdClient",
"open": "Налаштування",
"server_url": "URL панелі",
"tls_strict": "Суворий TLS",
"sign_out": "Вийти",
"reset_client": "Скинути клієнт",
"discovery_refresh": "Оновити",
"discovery_empty": "Серверів у локальній мережі не знайдено"
} }
} }
+20 -3
View File
@@ -744,7 +744,11 @@
"build_status_failed": "Thất bại", "build_status_failed": "Thất bại",
"builds_summary": "{{ready}} sẵn sàng · {{pending}} đang chờ · {{building}} đang build · {{failed}} thất bại", "builds_summary": "{{ready}} sẵn sàng · {{pending}} đang chờ · {{building}} đang build · {{failed}} thất bại",
"build_error_hint": "Lỗi build", "build_error_hint": "Lỗi build",
"preview_help": "Yêu cầu trợ giúp" "preview_help": "Yêu cầu trợ giúp",
"rdclient_tab": "RdClient desktop",
"rdclient_new_bundle": "Gói RdClient mới",
"rdclient_subtitle": "Tạo trình cài RdClient với URL bảng điều khiển nhúng",
"rdclient_server_url_hint": "URL công khai của bảng điều khiển BetterDesk (/remote)."
}, },
"server": { "server": {
"port": "Port", "port": "Port",
@@ -1050,7 +1054,9 @@
"file_loading": "Đang tải...", "file_loading": "Đang tải...",
"file_timeout": "Thiết bị từ xa không phản hồi. Truyền tệp có thể không được hỗ trợ hoặc bị tắt trên máy từ xa.", "file_timeout": "Thiết bị từ xa không phản hồi. Truyền tệp có thể không được hỗ trợ hoặc bị tắt trên máy từ xa.",
"codec": "Bộ giải mã video", "codec": "Bộ giải mã video",
"codec_auto": "Tự động (được khuyến nghị)" "codec_auto": "Tự động (được khuyến nghị)",
"remember_peer_password": "Ghi nhớ mật khẩu thiết bị trên máy này",
"save_peer_password": "Lưu mật khẩu"
}, },
"branding": { "branding": {
"identity_title": "Nhận diện thương hiệu", "identity_title": "Nhận diện thương hiệu",
@@ -3834,7 +3840,8 @@
"no_groups": "Không có nhóm", "no_groups": "Không có nhóm",
"no_tags": "Không có thẻ", "no_tags": "Không có thẻ",
"collapse_devices": "Thu gọn danh sách thiết bị", "collapse_devices": "Thu gọn danh sách thiết bị",
"expand_devices": "Mở rộng danh sách thiết bị" "expand_devices": "Mở rộng danh sách thiết bị",
"open_settings": "Cài đặt"
}, },
"rdclient_login": { "rdclient_login": {
"title": "Máy khách Desktop từ xa", "title": "Máy khách Desktop từ xa",
@@ -3854,5 +3861,15 @@
"network_error": "Lỗi mạng", "network_error": "Lỗi mạng",
"enter_6_digits": "Nhập đủ 6 chữ số", "enter_6_digits": "Nhập đủ 6 chữ số",
"invalid_code": "Mã không hợp lệ" "invalid_code": "Mã không hợp lệ"
},
"rdclient_settings": {
"title": "Cài đặt RdClient",
"open": "Cài đặt",
"server_url": "URL bảng điều khiển",
"tls_strict": "TLS nghiêm ngặt",
"sign_out": "Đăng xuất",
"reset_client": "Đặt lại client",
"discovery_refresh": "Làm mới",
"discovery_empty": "Không tìm thấy máy chủ trên mạng cục bộ"
} }
} }
+20 -3
View File
@@ -742,7 +742,11 @@
"usage_title": "連接詳情", "usage_title": "連接詳情",
"generated": "配置已生成", "generated": "配置已生成",
"server_required": "服務器地址爲必填項", "server_required": "服務器地址爲必填項",
"nothing_to_copy": "請先生成配置" "nothing_to_copy": "請先生成配置",
"rdclient_tab": "RdClient 桌面版",
"rdclient_new_bundle": "新增 RdClient 套件",
"rdclient_subtitle": "建置內嵌面板 URL 的 RdClient 安裝程式",
"rdclient_server_url_hint": "BetterDesk 主控台的公開 URL (/remote)。"
}, },
"download": { "download": {
"title": "下載 BetterDesk 代理", "title": "下載 BetterDesk 代理",
@@ -1058,7 +1062,9 @@
"2fa_required": "需要雙因素驗證", "2fa_required": "需要雙因素驗證",
"2fa_hint": "輸入驗證器應用中的6位數代碼", "2fa_hint": "輸入驗證器應用中的6位數代碼",
"2fa_invalid": "請輸入有效的6位數代碼", "2fa_invalid": "請輸入有效的6位數代碼",
"verify": "驗證" "verify": "驗證",
"remember_peer_password": "在此裝置上記住裝置密碼",
"save_peer_password": "儲存密碼"
}, },
"branding": { "branding": {
"identity_title": "品牌標識", "identity_title": "品牌標識",
@@ -3832,7 +3838,8 @@
"no_groups": "無群組", "no_groups": "無群組",
"no_tags": "無標籤", "no_tags": "無標籤",
"collapse_devices": "摺疊裝置清單", "collapse_devices": "摺疊裝置清單",
"expand_devices": "展開裝置清單" "expand_devices": "展開裝置清單",
"open_settings": "設定"
}, },
"rdclient_login": { "rdclient_login": {
"title": "遠端桌面用戶端", "title": "遠端桌面用戶端",
@@ -3852,5 +3859,15 @@
"network_error": "網路錯誤", "network_error": "網路錯誤",
"enter_6_digits": "請輸入全部6位數字", "enter_6_digits": "請輸入全部6位數字",
"invalid_code": "代碼無效" "invalid_code": "代碼無效"
},
"rdclient_settings": {
"title": "RdClient 設定",
"open": "設定",
"server_url": "面板 URL",
"tls_strict": "嚴格 TLS",
"sign_out": "登出",
"reset_client": "重設用戶端",
"discovery_refresh": "重新整理",
"discovery_empty": "區域網路中找不到伺服器"
} }
} }
+20 -3
View File
@@ -738,7 +738,11 @@
"build_status_failed": "失败", "build_status_failed": "失败",
"builds_summary": "{{ready}} 就绪 · {{pending}} 排队中 · {{building}} 构建中 · {{failed}} 失败", "builds_summary": "{{ready}} 就绪 · {{pending}} 排队中 · {{building}} 构建中 · {{failed}} 失败",
"build_error_hint": "构建错误", "build_error_hint": "构建错误",
"preview_help": "请求帮助" "preview_help": "请求帮助",
"rdclient_tab": "RdClient 桌面版",
"rdclient_new_bundle": "新建 RdClient 包",
"rdclient_subtitle": "构建嵌入面板 URL 的 RdClient 安装程序",
"rdclient_server_url_hint": "BetterDesk 控制台的公开 URL (/remote)。"
}, },
"server": { "server": {
"port": "端口", "port": "端口",
@@ -1044,7 +1048,9 @@
"use_cdap_fallback": "使用 CDAP 查看器", "use_cdap_fallback": "使用 CDAP 查看器",
"cdap_fallback_hint": "Open the lightweight JPEG-polling viewer that uses bd-signal/CDAP instead of the RustDesk relay. Useful when the peer is offline on the relay but reachable via the agent management channel.", "cdap_fallback_hint": "Open the lightweight JPEG-polling viewer that uses bd-signal/CDAP instead of the RustDesk relay. Useful when the peer is offline on the relay but reachable via the agent management channel.",
"codec": "视频编解码器", "codec": "视频编解码器",
"codec_auto": "自动(推荐)" "codec_auto": "自动(推荐)",
"remember_peer_password": "在此设备上记住设备密码",
"save_peer_password": "保存密码"
}, },
"branding": { "branding": {
"identity_title": "品牌标识", "identity_title": "品牌标识",
@@ -3828,7 +3834,8 @@
"no_groups": "无分组", "no_groups": "无分组",
"no_tags": "无标签", "no_tags": "无标签",
"collapse_devices": "折叠设备列表", "collapse_devices": "折叠设备列表",
"expand_devices": "展开设备列表" "expand_devices": "展开设备列表",
"open_settings": "设置"
}, },
"rdclient_login": { "rdclient_login": {
"title": "远程桌面客户端", "title": "远程桌面客户端",
@@ -3848,5 +3855,15 @@
"network_error": "网络错误", "network_error": "网络错误",
"enter_6_digits": "请输入全部6位数字", "enter_6_digits": "请输入全部6位数字",
"invalid_code": "代码无效" "invalid_code": "代码无效"
},
"rdclient_settings": {
"title": "RdClient 设置",
"open": "设置",
"server_url": "面板 URL",
"tls_strict": "严格 TLS",
"sign_out": "退出登录",
"reset_client": "重置客户端",
"discovery_refresh": "刷新",
"discovery_empty": "在本地网络中未找到服务器"
} }
} }
+59 -22
View File
@@ -8,16 +8,16 @@ html {
} }
html.rd-desk-desktop { html.rd-desk-desktop {
height: 100%; height: var(--rd-desk-vh, 100vh);
height: 100vh; max-height: var(--rd-desk-vh, 100vh);
max-height: 100vh;
overflow: hidden; overflow: hidden;
overscroll-behavior: none;
} }
.rd-desk-body { .rd-desk-body {
margin: 0; margin: 0;
height: 100%; height: 100%;
min-height: 100%; min-height: 0;
overflow: hidden; overflow: hidden;
background: var(--bg-primary, #0d1117); background: var(--bg-primary, #0d1117);
color: var(--text-primary, #e6edf3); color: var(--text-primary, #e6edf3);
@@ -27,9 +27,14 @@ html.rd-desk-desktop {
} }
.rd-desk-desktop.rd-desk-body { .rd-desk-desktop.rd-desk-body {
height: 100vh; position: fixed;
max-height: 100vh; inset: 0;
width: 100%;
height: var(--rd-desk-vh, 100vh) !important;
max-height: var(--rd-desk-vh, 100vh);
min-height: 0; min-height: 0;
overflow: hidden !important;
overscroll-behavior: none;
} }
.rd-desk-app { .rd-desk-app {
@@ -43,8 +48,10 @@ html.rd-desk-desktop {
} }
.rd-desk-desktop .rd-desk-app { .rd-desk-desktop .rd-desk-app {
height: 100vh; position: absolute;
max-height: 100vh; inset: 0;
height: 100% !important;
max-height: 100% !important;
} }
/* ── Header ── */ /* ── Header ── */
@@ -193,6 +200,35 @@ html.rd-desk-desktop {
75% { transform: translateX(4px); } 75% { transform: translateX(4px); }
} }
/* ── Scroll areas (independent sidebar + device list) ── */
.rd-desk-sidebar-scroll,
.rd-desk-devices-scroll {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb, var(--bg-elevated, #30363d)) var(--scrollbar-track, var(--bg-secondary, #161b22));
}
.rd-desk-sidebar-scroll::-webkit-scrollbar,
.rd-desk-devices-scroll::-webkit-scrollbar {
width: var(--scrollbar-width, 8px);
height: var(--scrollbar-width, 8px);
}
.rd-desk-sidebar-scroll::-webkit-scrollbar-track,
.rd-desk-devices-scroll::-webkit-scrollbar-track {
background: var(--scrollbar-track, var(--bg-secondary, #161b22));
}
.rd-desk-sidebar-scroll::-webkit-scrollbar-thumb,
.rd-desk-devices-scroll::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb, var(--bg-elevated, #30363d));
border-radius: 999px;
}
.rd-desk-sidebar-scroll::-webkit-scrollbar-thumb:hover,
.rd-desk-devices-scroll::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover, var(--border-hover, #484f58));
}
/* ── Workspace ── */ /* ── Workspace ── */
.rd-desk-workspace { .rd-desk-workspace {
display: flex; display: flex;
@@ -204,14 +240,24 @@ html.rd-desk-desktop {
/* ── Sidebar ── */ /* ── Sidebar ── */
.rd-desk-sidebar { .rd-desk-sidebar {
width: 240px; width: 240px;
flex-shrink: 0; flex: 0 0 240px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
border-right: 1px solid var(--border-primary, #30363d); border-right: 1px solid var(--border-primary, #30363d);
background: var(--surface-glass-bg-secondary, var(--bg-secondary, #161b22)); background: var(--surface-glass-bg-secondary, var(--bg-secondary, #161b22));
padding: 12px 0 12px; padding: 0;
}
.rd-desk-sidebar-scroll {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
padding: 12px 0;
-webkit-overflow-scrolling: touch;
} }
.rd-desk-nav-section { .rd-desk-nav-section {
@@ -267,20 +313,13 @@ html.rd-desk-desktop {
.rd-desk-nav-body { .rd-desk-nav-body {
min-height: 0; min-height: 0;
max-height: min(220px, 28vh); overflow: visible;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
} }
.rd-desk-nav-section.is-collapsed .rd-desk-nav-body { .rd-desk-nav-section.is-collapsed .rd-desk-nav-body {
display: none; display: none;
} }
.rd-desk-desktop .rd-desk-nav-body {
max-height: min(180px, 24vh);
}
.rd-desk-nav-list { .rd-desk-nav-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -359,6 +398,7 @@ html.rd-desk-desktop {
/* ── Content ── */ /* ── Content ── */
.rd-desk-content { .rd-desk-content {
flex: 1; flex: 1;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
@@ -872,16 +912,13 @@ html.rd-desk-desktop {
} }
.rd-desk-sidebar { .rd-desk-sidebar {
flex: 0 0 auto;
width: 100%; width: 100%;
max-height: min(240px, 35vh); max-height: min(240px, 35vh);
border-right: none; border-right: none;
border-bottom: 1px solid var(--border-primary, #30363d); border-bottom: 1px solid var(--border-primary, #30363d);
} }
.rd-desk-nav-body {
max-height: min(140px, 18vh);
}
.rd-desk-quick-label { .rd-desk-quick-label {
display: none; display: none;
} }
+10 -3
View File
@@ -46,6 +46,7 @@
slugManual: false, slugManual: false,
previewTimer: null, previewTimer: null,
buildsPollTimer: null, buildsPollTimer: null,
productType: 'agent',
}; };
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
@@ -421,14 +422,16 @@
els['gen-save-btn'].disabled = true; els['gen-save-btn'].disabled = true;
} }
function setEditorForNew() { function setEditorForNew(productType) {
state.currentId = 'new'; state.currentId = 'new';
state.currentBundle = null; state.currentBundle = null;
state.currentBuilds = []; state.currentBuilds = [];
state.dirty = false; state.dirty = false;
state.slugManual = false; state.slugManual = false;
state.productType = productType || 'agent';
stopBuildsPoll(); stopBuildsPoll();
els['gen-editor-title'].innerHTML = `<span class="material-icons">add_circle</span> ${escapeText(t('generator.new_bundle', 'New bundle'))}`; const titleKey = state.productType === 'rdclient' ? 'generator.rdclient_new_bundle' : 'generator.new_bundle';
els['gen-editor-title'].innerHTML = `<span class="material-icons">add_circle</span> ${escapeText(t(titleKey, 'New bundle'))}`;
els['gen-name'].value = ''; els['gen-name'].value = '';
if (els['gen-slug']) els['gen-slug'].value = ''; if (els['gen-slug']) els['gen-slug'].value = '';
writeBranding(DEFAULT_BRANDING); writeBranding(DEFAULT_BRANDING);
@@ -453,6 +456,7 @@
function setEditorForBundle(bundle) { function setEditorForBundle(bundle) {
state.currentId = bundle.bundle_id; state.currentId = bundle.bundle_id;
state.currentBundle = bundle; state.currentBundle = bundle;
state.productType = bundle.product_type || 'agent';
state.dirty = false; state.dirty = false;
state.slugManual = true; state.slugManual = true;
stopBuildsPoll(); stopBuildsPoll();
@@ -514,6 +518,7 @@
name: els['gen-name'].value.trim(), name: els['gen-name'].value.trim(),
slug: readSlugInput(), slug: readSlugInput(),
branding: readBranding(), branding: readBranding(),
product_type: state.productType || 'agent',
}; };
if (!payload.name) { if (!payload.name) {
showErrors([t('generator.errors.name_required', 'Bundle name is required')]); showErrors([t('generator.errors.name_required', 'Bundle name is required')]);
@@ -653,7 +658,9 @@
} }
function bindEvents() { function bindEvents() {
els['gen-new-bundle'].addEventListener('click', () => setEditorForNew()); els['gen-new-bundle'].addEventListener('click', () => setEditorForNew('agent'));
const rdBtn = $('gen-new-rdclient');
if (rdBtn) rdBtn.addEventListener('click', () => setEditorForNew('rdclient'));
els['gen-save-btn'].addEventListener('click', saveBundle); els['gen-save-btn'].addEventListener('click', saveBundle);
els['gen-rebuild-btn'].addEventListener('click', rebuildAllBuilds); els['gen-rebuild-btn'].addEventListener('click', rebuildAllBuilds);
els['gen-revoke-btn'].addEventListener('click', toggleRevoke); els['gen-revoke-btn'].addEventListener('click', toggleRevoke);
+89 -19
View File
@@ -88,23 +88,23 @@
&& typeof window.VideoDecoder !== 'undefined'; && typeof window.VideoDecoder !== 'undefined';
} }
/** @type {Promise<string[]>|null} */
let _decodableCodecsPromise = null;
/** Codecs confirmed broken at runtime (e.g. AV1 on WebKit HW). */
const _blockedWireCodecs = new Set();
/** /**
* Build the ordered list of codecs the operator can decode, advertised to * Sync fallback before async probe completes.
* the agent in the `desktop_start` payload. The agent intersects this with
* its own GPU/encoder ability and picks the first match in its own
* preference order (AV1 VP9 H264 WebP).
*
* - Secure context (WebCodecs): advertise AV1/VP9/H264 for max quality.
* - Plain HTTP with MSE H.264: advertise H264 so the agent still sends a
* GPU-encoded video stream (decoded via JMuxer/MSE) instead of falling
* back to MJPEG this is the key to smooth video without HTTPS.
* - Otherwise: image formats only (WebP/JPEG).
* @returns {string[]} * @returns {string[]}
*/ */
function decodableCodecs() { function decodableCodecsSync() {
const list = []; const list = [];
if (hasWebCodecs()) { if (hasWebCodecs()) {
list.push('av1', 'vp9', 'h264'); const allowAv1 = typeof RDVideo !== 'undefined'
&& RDVideo.av1ReliableOnRuntime
&& RDVideo.av1ReliableOnRuntime();
if (allowAv1 && !_blockedWireCodecs.has('av1')) list.push('av1');
list.push('vp9', 'h264');
} else if (hasMseH264()) { } else if (hasMseH264()) {
list.push('h264'); list.push('h264');
} }
@@ -112,6 +112,33 @@
return list; return list;
} }
/**
* Probe WebCodecs support and return wire codecs safe to advertise.
* @returns {Promise<string[]>}
*/
function decodableCodecs() {
if (!_decodableCodecsPromise) {
_decodableCodecsPromise = (async () => {
if (typeof RDVideo !== 'undefined' && RDVideo.probeDecodableWireCodecs) {
try {
const probed = await RDVideo.probeDecodableWireCodecs();
return probed.filter((c) => !_blockedWireCodecs.has(c));
} catch { /* fall through */ }
}
return decodableCodecsSync();
})();
}
return _decodableCodecsPromise.then((list) =>
list.filter((c) => !_blockedWireCodecs.has(c))
);
}
function blockWireCodec(codec) {
if (!codec) return;
_blockedWireCodecs.add(String(codec).toLowerCase());
_decodableCodecsPromise = null;
}
/** /**
* Stub renderer that mirrors the subset of `RDRenderer` used by * Stub renderer that mirrors the subset of `RDRenderer` used by
* `remote.js` (resize + scale mode). Frames are painted directly by * `remote.js` (resize + scale mode). Frames are painted directly by
@@ -217,6 +244,8 @@
// from `desktop_meta` and defaults to the legacy MJPEG path. // from `desktop_meta` and defaults to the legacy MJPEG path.
this._activeFormat = 'jpeg'; this._activeFormat = 'jpeg';
this._codecString = null; this._codecString = null;
this._codecFallbackDone = false;
this._videoRenderedFrames = 0;
this._video = null; // RDVideo instance (lazy) this._video = null; // RDVideo instance (lazy)
this._gotKeyframe = false; this._gotKeyframe = false;
this._keyframeRequestedAt = 0; this._keyframeRequestedAt = 0;
@@ -268,6 +297,7 @@
async connect() { async connect() {
this._setState('connecting'); this._setState('connecting');
this._codecFallbackDone = false;
this._emit('log', 'Opening CDAP desktop session…'); this._emit('log', 'Opening CDAP desktop session…');
console.log('[CDAP] connect()', this.deviceId); console.log('[CDAP] connect()', this.deviceId);
@@ -495,6 +525,17 @@
// ── Internal: WS lifecycle ─────────────────────────────────────── // ── Internal: WS lifecycle ───────────────────────────────────────
_handleOpen() { _handleOpen() {
const self = this;
decodableCodecs().then(function (codecs) {
if (!self._ws || self._ws.readyState !== WebSocket.OPEN) return;
self._sendDesktopStart(codecs);
}).catch(function () {
if (!self._ws || self._ws.readyState !== WebSocket.OPEN) return;
self._sendDesktopStart(decodableCodecsSync());
});
}
_sendDesktopStart(codecs) {
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
const screenW = (window.screen && window.screen.width) || 1920; const screenW = (window.screen && window.screen.width) || 1920;
@@ -515,7 +556,7 @@
// sends a GPU-encoded video stream. Over plain HTTP this still // sends a GPU-encoded video stream. Over plain HTTP this still
// includes H.264 (decoded via JMuxer/MSE), avoiding the slow // includes H.264 (decoded via JMuxer/MSE), avoiding the slow
// MJPEG fallback. See decodableCodecs(). // MJPEG fallback. See decodableCodecs().
codecs: decodableCodecs(), codecs: codecs,
video_codec: 'auto', video_codec: 'auto',
}); });
@@ -742,21 +783,45 @@
return; return;
} }
this._closeVideoDecoder(); this._closeVideoDecoder();
this._videoRenderedFrames = 0;
const v = new RDVideo(); const v = new RDVideo();
this._video = v; this._video = v;
v.onFrame = (frame) => this._drawVideoFrame(frame); v.onFrame = (frame) => {
this._videoRenderedFrames++;
this._drawVideoFrame(frame);
};
v.onError = () => { v.onError = () => {
// A decode error usually means we fed a delta frame before the
// first keyframe — ask the agent for a fresh keyframe.
this._gotKeyframe = false; this._gotKeyframe = false;
this._requestKeyframe(); this._requestKeyframe();
}; };
v.init(codecName).catch((err) => { v.onCodecFailed = (failedCodec) => {
this._handleCodecFailed(failedCodec);
};
v.onNeedKeyframe = () => this._requestKeyframe();
v.init(codecName, { codecString: this._codecString || null }).catch((err) => {
console.warn('[CDAP] video decoder init failed:', err && err.message); console.warn('[CDAP] video decoder init failed:', err && err.message);
this._video = null; this._handleCodecFailed(codecName);
}); });
} }
_handleCodecFailed(codec) {
const c = String(codec || '').toLowerCase();
if (!c || this._codecFallbackDone) return;
blockWireCodec(c);
this._codecFallbackDone = true;
this._closeVideoDecoder();
this._gotKeyframe = false;
this._emit('log', 'Codec ' + c.toUpperCase() + ' failed — reconnecting with VP9/H.264…');
const ws = this._ws;
const deviceId = this.deviceId;
try { if (ws) ws.close(4002, 'codec_fallback'); } catch { /* noop */ }
setTimeout(() => {
if (this._state === 'disconnected' || this._state === 'error') {
this.connect().catch(() => {});
}
}, 400);
}
/** /**
* Feed one binary video frame to the decoder. The agent prefixes each * Feed one binary video frame to the decoder. The agent prefixes each
* access unit with a single flag byte (bit0 = keyframe); the Node proxy * access unit with a single flag byte (bit0 = keyframe); the Node proxy
@@ -788,7 +853,12 @@
this._gotKeyframe = true; this._gotKeyframe = true;
} }
this._video.decode({ data: payload, key: isKey, codec: this._activeFormat }); this._video.decode({
data: payload,
key: isKey,
codec: this._activeFormat,
codecString: this._codecString || null
});
} }
/** /**
+33
View File
@@ -62,6 +62,7 @@ class RDClient {
this._codecAbilities = null; // probed VideoDecoder support map this._codecAbilities = null; // probed VideoDecoder support map
this._preferCodec = opts.preferCodec || 'Auto'; this._preferCodec = opts.preferCodec || 'Auto';
this._adaptivePaused = false; // true once the user picks codec/quality manually this._adaptivePaused = false; // true once the user picks codec/quality manually
this._codecFallbackDone = false; // one automatic downgrade per session
// Relay state tracking // Relay state tracking
this._relayFrameIdx = 0; // Counter for relay frames (debugging) this._relayFrameIdx = 0; // Counter for relay frames (debugging)
@@ -1052,6 +1053,7 @@ class RDClient {
*/ */
_startSession() { _startSession() {
this._setState('streaming'); this._setState('streaming');
this._codecFallbackDone = false;
this.conn.setConnected(); this.conn.setConnected();
// Enable file transfer // Enable file transfer
@@ -1068,6 +1070,9 @@ class RDClient {
this._sendPeerMessage(this.proto.buildMisc('refreshVideo', true)); this._sendPeerMessage(this.proto.buildMisc('refreshVideo', true));
} }
}; };
this.video.onCodecFailed = (failedCodec) => {
this._handleCodecFallback(failedCodec);
};
// Request keyframe on resize/fullscreen to fix blur // Request keyframe on resize/fullscreen to fix blur
this.renderer.onResizeRefresh = () => { this.renderer.onResizeRefresh = () => {
@@ -1325,6 +1330,7 @@ class RDClient {
this.audio.close(); this.audio.close();
this.fileTransfer.disable(); this.fileTransfer.disable();
this.conn.close(); this.conn.close();
this._codecFallbackDone = false;
} }
// ---- Public Utility Methods ---- // ---- Public Utility Methods ----
@@ -1651,6 +1657,33 @@ class RDClient {
this._emit('codec_changed', name); this._emit('codec_changed', name);
} }
/**
* Downgrade to the next working codec when WebCodecs fails at runtime.
* @param {string} failedCodec
*/
_handleCodecFallback(failedCodec) {
const failed = String(failedCodec || '').toLowerCase();
if (!failed || this._codecFallbackDone || this._state !== 'streaming') return;
this._codecFallbackDone = true;
if (!this._codecAbilities) this._codecAbilities = {};
this._codecAbilities[failed] = false;
const order = ['vp9', 'h264', 'vp8'];
let next = 'H264';
for (let i = 0; i < order.length; i++) {
const candidate = order[i];
if (candidate === failed) continue;
if (this._codecAbilities[candidate] !== false) {
next = candidate === 'h264' ? 'H264' : candidate.toUpperCase();
break;
}
}
this._emit('log', 'Codec ' + failed.toUpperCase() + ' failed — switching to ' + next);
this.setCodec(next);
}
/** /**
* Request remote device restart * Request remote device restart
*/ */
+240 -17
View File
@@ -65,9 +65,12 @@ class RDVideo {
this._codecConfig = null; this._codecConfig = null;
/** @type {boolean} Whether we already retried software decoding after a hardware failure */ /** @type {boolean} Whether we already retried software decoding after a hardware failure */
this._softwareRetry = false; this._softwareRetry = false;
/** @type {number} Last decoder error timestamp (ms) for throttled recovery */ /** @type {Function|null} Called when decoder cannot recover (codec switch needed) */
this._lastDecodeErrorTime = 0; this.onCodecFailed = null;
} /** @type {number} Decode attempts since last successful frame */
this._decodeErrorsSinceFrame = 0;
/** @type {boolean} Whether AV1 description was applied from a keyframe */
this._av1DescriptionApplied = false;
/** /**
* Check if hardware WebCodecs is supported (requires secure context) * Check if hardware WebCodecs is supported (requires secure context)
@@ -85,6 +88,56 @@ class RDVideo {
return window.isSecureContext === true; return window.isSecureContext === true;
} }
/** WebKitGTK / Safari — AV1 HW decode is often broken; prefer software. */
static isWebKit() {
const ua = navigator.userAgent || '';
return /AppleWebKit/i.test(ua) && !/Edg\//.test(ua) && !/Chrome\//.test(ua);
}
/** Chromium / WebView2 — full WebCodecs stack (incl. AV1 on recent builds). */
static isChromiumWebView() {
const ua = navigator.userAgent || '';
return /Chrome\//.test(ua) || /Edg\//.test(ua);
}
/** Tauri RdClient desktop shell (WebKitGTK on Linux, WKWebView on macOS, WebView2 on Windows). */
static isRdClientDesktop() {
return window.__BETTERDESK_RDCLIENT_DESKTOP__ === true
|| !!(window.__TAURI__ && window.__TAURI__.core);
}
/** Linux WebKitGTK — WebCodecs AV1 is advertised but fails at decode time. */
static isWebKitGTK() {
if (RDVideo.isChromiumWebView()) return false;
const ua = navigator.userAgent || '';
if (!/AppleWebKit/i.test(ua)) return false;
if (/Linux/i.test(ua)) return true;
return RDVideo.isRdClientDesktop() && !/Macintosh|Windows|CrOS/i.test(ua);
}
/**
* Whether AV1 should be offered to the encoder. WebKit runtimes often pass
* VideoDecoder.isConfigSupported for AV1 but fail with "Decode error" at runtime.
* @returns {boolean}
*/
static av1ReliableOnRuntime() {
if (RDVideo.isWebKitGTK()) return false;
if (RDVideo.isWebKit() && !RDVideo.isChromiumWebView()) return false;
return true;
}
/**
* Default hardwareAcceleration for a logical codec on this runtime.
* @param {string} codecName
* @returns {string|undefined}
*/
static defaultAcceleration(codecName) {
if (codecName === 'av1' && RDVideo.isWebKit()) {
return 'prefer-software';
}
return 'prefer-hardware';
}
/** /**
* Check if JMuxer fallback is available * Check if JMuxer fallback is available
* @returns {boolean} * @returns {boolean}
@@ -102,7 +155,7 @@ class RDVideo {
const map = { const map = {
vp9: ['vp09.00.10.08', 'vp09.00.40.08', 'vp09.00.50.08'], vp9: ['vp09.00.10.08', 'vp09.00.40.08', 'vp09.00.50.08'],
h264: ['avc1.640028', 'avc1.4d4028', 'avc1.42E01E'], h264: ['avc1.640028', 'avc1.4d4028', 'avc1.42E01E'],
av1: ['av01.0.08M.08', 'av01.0.05M.08', 'av01.0.04M.08', 'av01.0.01M.08', 'av01.0.15M.08'], av1: ['av01.0.04M.08', 'av01.0.08M.08', 'av01.0.05M.08', 'av01.0.01M.08', 'av01.0.15M.08'],
vp8: ['vp8'], vp8: ['vp8'],
h265: ['hev1.1.6.L93.B0', 'hvc1.1.6.L93.B0'] h265: ['hev1.1.6.L93.B0', 'hvc1.1.6.L93.B0']
}; };
@@ -115,12 +168,15 @@ class RDVideo {
* @param {string} [accel] hardwareAcceleration preference * @param {string} [accel] hardwareAcceleration preference
* @returns {Promise<Object|null>} supported VideoDecoderConfig * @returns {Promise<Object|null>} supported VideoDecoderConfig
*/ */
static async resolveCodecConfig(codecName, accel) { static async resolveCodecConfig(codecName, accel, explicitCodec) {
const candidates = RDVideo.codecCandidates(codecName); const candidates = explicitCodec
? [explicitCodec].concat(RDVideo.codecCandidates(codecName).filter((c) => c !== explicitCodec))
: RDVideo.codecCandidates(codecName);
if (!candidates.length) return null; if (!candidates.length) return null;
const probes = accel const baseAccel = accel || RDVideo.defaultAcceleration(codecName);
? [{ hardwareAcceleration: accel }, {}] const probes = baseAccel
? [{ hardwareAcceleration: baseAccel }, { hardwareAcceleration: 'prefer-software' }, {}]
: [{ hardwareAcceleration: 'prefer-hardware' }, { hardwareAcceleration: 'prefer-software' }, {}]; : [{ hardwareAcceleration: 'prefer-hardware' }, { hardwareAcceleration: 'prefer-software' }, {}];
for (const codec of candidates) { for (const codec of candidates) {
@@ -158,17 +214,44 @@ class RDVideo {
const names = ['vp9', 'h264', 'av1', 'vp8', 'h265']; const names = ['vp9', 'h264', 'av1', 'vp8', 'h265'];
const result = {}; const result = {};
for (const name of names) { for (const name of names) {
result[name] = !!(await RDVideo.resolveCodecConfig(name)); if (name === 'av1' && !RDVideo.av1ReliableOnRuntime()) {
result[name] = false;
continue;
}
result[name] = !!(await RDVideo.resolveCodecConfig(name, RDVideo.defaultAcceleration(name)));
} }
return result; return result;
} }
/**
* Ordered wire codecs safe to advertise to the agent (most efficient first).
* AV1 is omitted when the runtime cannot decode it reliably (WebKit HW bugs).
* @returns {Promise<string[]>}
*/
static async probeDecodableWireCodecs() {
const list = [];
if (!RDVideo.isSupported()) {
if (RDVideo.isJMuxerAvailable()) list.push('h264');
list.push('webp', 'jpeg');
return list;
}
const support = await RDVideo.getSupportedCodecs();
if (support.av1 && RDVideo.av1ReliableOnRuntime()) list.push('av1');
if (support.vp9) list.push('vp9');
if (support.h264) list.push('h264');
list.push('webp', 'jpeg');
return list;
}
/** /**
* Initialize decoder for a specific codec. * Initialize decoder for a specific codec.
* Uses WebCodecs if available, otherwise falls back to JMuxer for H.264. * Uses WebCodecs if available, otherwise falls back to JMuxer for H.264.
* @param {string} codecName - vp9, h264, av1, vp8 * @param {string} codecName - vp9, h264, av1, vp8
* @param {Object} [opts]
* @param {string} [opts.codecString] WebCodecs codec string from agent
*/ */
async init(codecName) { async init(codecName, opts) {
opts = opts || {};
if (this.decoder || this._jmuxer) { if (this.decoder || this._jmuxer) {
this.close(); this.close();
} }
@@ -207,16 +290,18 @@ class RDVideo {
throw new Error(`Unsupported codec: ${codecName}`); throw new Error(`Unsupported codec: ${codecName}`);
} }
// Choose acceleration: prefer hardware, but fall back to software after const accel = this._softwareRetry
// a hardware decode failure (some Linux/Chrome setups have flaky ? 'prefer-software'
// hardware H.264 paths that silently stop producing frames). : RDVideo.defaultAcceleration(codecName);
const accel = this._softwareRetry ? 'prefer-software' : 'prefer-hardware';
const resolved = await RDVideo.resolveCodecConfig(codecName, accel); const resolved = await RDVideo.resolveCodecConfig(codecName, accel, opts.codecString || null);
if (!resolved || !resolved.codec) { if (!resolved || !resolved.codec) {
throw new Error(`Codec ${codecName} not supported by browser`); throw new Error(`Codec ${codecName} not supported by browser`);
} }
this._softwareRetry = resolved.hardwareAcceleration === 'prefer-software'
|| resolved.hardwareAcceleration === 'no-preference';
this.decoder = new VideoDecoder({ this.decoder = new VideoDecoder({
output: (frame) => this._handleDecodedFrame(frame), output: (frame) => this._handleDecodedFrame(frame),
error: (err) => this._handleError(err) error: (err) => this._handleError(err)
@@ -228,8 +313,16 @@ class RDVideo {
hardwareAcceleration: resolved.hardwareAcceleration || accel, hardwareAcceleration: resolved.hardwareAcceleration || accel,
optimizeForLatency: true optimizeForLatency: true
}; };
if (opts.description) {
this._codecConfig.description = opts.description;
}
this.decoder.configure(this._codecConfig); this.decoder.configure(this._codecConfig);
console.log('[RDVideo] Configured', codecName, this._codecConfig.codec,
'hw=' + (this._codecConfig.hardwareAcceleration || 'default'),
'runtime=' + (RDVideo.isWebKitGTK() ? 'webkitgtk'
: (RDVideo.isChromiumWebView() ? 'chromium' : 'other')));
this.fallbackMode = false; this.fallbackMode = false;
this.currentCodec = codecName; this.currentCodec = codecName;
this.frameCount = 0; this.frameCount = 0;
@@ -239,6 +332,8 @@ class RDVideo {
// frame. Drop deltas until one arrives and proactively request one. // frame. Drop deltas until one arrives and proactively request one.
this._needKeyframe = true; this._needKeyframe = true;
this._decodeInputCount = 0; this._decodeInputCount = 0;
this._decodeErrorsSinceFrame = 0;
this._av1DescriptionApplied = false;
if (this.onNeedKeyframe) { if (this.onNeedKeyframe) {
this.onNeedKeyframe(); this.onNeedKeyframe();
} }
@@ -588,7 +683,24 @@ class RDVideo {
// Switch codec if needed // Switch codec if needed
if (frameData.codec && frameData.codec !== this.currentCodec) { if (frameData.codec && frameData.codec !== this.currentCodec) {
await this.init(frameData.codec); await this.init(frameData.codec, { codecString: frameData.codecString });
}
// AV1: apply codec description from the first keyframe (WebKitGTK needs this).
if (this.currentCodec === 'av1' && frameData.key && !this._av1DescriptionApplied) {
const desc = RDVideo.av1DescriptionFromKeyframe(frameData.data);
if (desc) {
try {
this._codecConfig.description = desc;
if (this.decoder && this.decoder.state === 'configured') {
this.decoder.configure(this._codecConfig);
}
this._av1DescriptionApplied = true;
this._needKeyframe = false;
} catch (e) {
console.warn('[RDVideo] AV1 description configure failed:', e && e.message);
}
}
} }
// JMuxer fallback mode // JMuxer fallback mode
@@ -710,6 +822,7 @@ class RDVideo {
*/ */
_handleDecodedFrame(frame) { _handleDecodedFrame(frame) {
this.frameCount++; this.frameCount++;
this._decodeErrorsSinceFrame = 0;
// Record timestamp so getStats() can report real FPS on the WebCodecs // Record timestamp so getStats() can report real FPS on the WebCodecs
// path (previously only the JMuxer fallback fed this array, so HTTPS // path (previously only the JMuxer fallback fed this array, so HTTPS
// sessions always reported 0 FPS). // sessions always reported 0 FPS).
@@ -742,6 +855,7 @@ class RDVideo {
*/ */
_handleError(err) { _handleError(err) {
console.error('[RDVideo] Decoder error:', err && err.message ? err.message : err); console.error('[RDVideo] Decoder error:', err && err.message ? err.message : err);
this._decodeErrorsSinceFrame++;
if (this.onError) { if (this.onError) {
this.onError(err); this.onError(err);
} }
@@ -750,6 +864,15 @@ class RDVideo {
return; return;
} }
// WebKitGTK / Safari: AV1 passes isConfigSupported but fails at runtime — switch codec immediately.
if (this.currentCodec === 'av1' && !RDVideo.av1ReliableOnRuntime()) {
console.warn('[RDVideo] AV1 decode failed on WebKit runtime — requesting VP9/H.264 fallback');
if (this.onCodecFailed) {
this.onCodecFailed(this.currentCodec, err);
}
return;
}
// Throttle recovery attempts to once per second. // Throttle recovery attempts to once per second.
const now = performance.now(); const now = performance.now();
if (now - this._lastDecodeErrorTime < 1000) { if (now - this._lastDecodeErrorTime < 1000) {
@@ -761,9 +884,14 @@ class RDVideo {
if (!this._softwareRetry) { if (!this._softwareRetry) {
this._softwareRetry = true; this._softwareRetry = true;
console.warn('[RDVideo] Rebuilding decoder with software decoding fallback'); console.warn('[RDVideo] Rebuilding decoder with software decoding fallback');
} else if (this._decodeErrorsSinceFrame > 8 && this.onCodecFailed) {
console.warn('[RDVideo] Codec', this.currentCodec, 'unrecoverable — requesting fallback');
this.onCodecFailed(this.currentCodec, err);
return;
} }
this._needKeyframe = true; this._needKeyframe = true;
this._av1DescriptionApplied = false;
try { try {
if (this.decoder && this.decoder.state !== 'closed') { if (this.decoder && this.decoder.state !== 'closed') {
@@ -774,15 +902,108 @@ class RDVideo {
} }
const codecName = this.currentCodec; const codecName = this.currentCodec;
const savedCodecString = this._codecConfig && this._codecConfig.codec;
this.decoder = null; this.decoder = null;
this.initialized = false; this.initialized = false;
if (codecName) { if (codecName) {
this.init(codecName).catch((e) => { this.init(codecName, { codecString: savedCodecString }).catch((e) => {
console.error('[RDVideo] Decoder rebuild failed:', e && e.message ? e.message : e); console.error('[RDVideo] Decoder rebuild failed:', e && e.message ? e.message : e);
if (this.onCodecFailed) {
this.onCodecFailed(codecName, e);
}
}); });
} }
} }
/**
* Build WebCodecs AV1CodecConfigurationRecord (av1C) from a keyframe OBU stream.
* @param {Uint8Array} data
* @returns {Uint8Array|null}
*/
static av1DescriptionFromKeyframe(data) {
if (!data || data.length < 2) return null;
let i = 0;
let fullSeqObu = null;
let seqPayload = null;
while (i < data.length) {
const obuStart = i;
const hdr = data[i++];
const obuType = (hdr >> 3) & 0x0F;
const extFlag = (hdr >> 2) & 0x01;
const hasSize = (hdr >> 1) & 0x01;
if (extFlag === 1 && i < data.length) i++;
let size;
if (hasSize === 1) {
const parsed = RDVideo._readLeb128(data, i);
if (!parsed) break;
size = parsed.value;
i += parsed.bytes;
} else {
size = data.length - i;
}
if (i + size > data.length) break;
if (obuType === 1) {
fullSeqObu = data.subarray(obuStart, i + size);
seqPayload = data.subarray(i, i + size);
}
i += size;
}
if (!fullSeqObu || !seqPayload || seqPayload.length < 4) return null;
const br = { data: seqPayload, pos: 0 };
const read = (n) => RDVideo._readBits(br, n);
const profile = read(3);
const level = read(5);
const tier = read(1);
const highBitdepth = read(1);
const twelveBit = read(1);
const monochrome = read(1);
const subsamplingX = read(1);
const subsamplingY = read(1);
const samplePos = read(2);
read(3); // reserved
const out = new Uint8Array(4 + fullSeqObu.length);
out[0] = 0x81;
out[1] = (profile << 5) | (level & 0x1F);
out[2] = ((level >> 5) & 0xFF) | (tier << 7) | (highBitdepth << 6)
| (twelveBit << 5) | (monochrome << 4) | (subsamplingX << 3)
| (subsamplingY << 2) | (samplePos >> 1);
out[3] = ((samplePos & 1) << 7);
out.set(fullSeqObu, 4);
return out;
}
/** @private */
static _readLeb128(b, start) {
let v = 0;
for (let i = 0; i < 8 && start + i < b.length; i++) {
const byte = b[start + i];
v |= (byte & 0x7F) << (7 * i);
if ((byte & 0x80) === 0) {
return { value: v, bytes: i + 1 };
}
}
return null;
}
/** @private */
static _readBits(br, n) {
let v = 0;
for (let i = 0; i < n; i++) {
const bytePos = br.pos >> 3;
if (bytePos >= br.data.length) {
br.pos++;
v <<= 1;
continue;
}
const bit = (br.data[bytePos] >> (7 - (br.pos & 7))) & 1;
v = (v << 1) | bit;
br.pos++;
}
return v;
}
/** /**
* Flush pending frames * Flush pending frames
*/ */
@@ -874,6 +1095,8 @@ class RDVideo {
this.fallbackMode = false; this.fallbackMode = false;
this.currentCodec = null; this.currentCodec = null;
this.initialized = false; this.initialized = false;
this._softwareRetry = false;
this._av1DescriptionApplied = false;
} }
} }
+73 -2
View File
@@ -6,7 +6,7 @@ var RdClientSecureStore = (function () {
'use strict'; 'use strict';
var DB_NAME = 'betterdesk-rdclient'; var DB_NAME = 'betterdesk-rdclient';
var DB_VERSION = 1; var DB_VERSION = 2;
var STORE = 'vault'; var STORE = 'vault';
var KEY_ID = 'device-key'; var KEY_ID = 'device-key';
var CRED_ID = 'credentials'; var CRED_ID = 'credentials';
@@ -198,13 +198,84 @@ var RdClientSecureStore = (function () {
} }
} }
function peerKey(deviceId) {
return 'peer:' + String(deviceId || '').trim();
}
async function savePeerPassword(deviceId, password) {
if (!deviceId || !password || !window.crypto || !window.crypto.subtle) return;
var db = await openDb();
try {
var key = await getOrCreateDeviceKey(db);
var encrypted = await encryptPassword(key, password);
await idbPut(db, peerKey(deviceId), {
encrypted: encrypted,
updatedAt: Date.now()
});
} finally {
db.close();
}
}
async function loadPeerPassword(deviceId) {
if (!deviceId || !window.crypto || !window.crypto.subtle || !window.indexedDB) return '';
var db = await openDb();
try {
var record = await idbGet(db, peerKey(deviceId));
if (!record || !record.encrypted) return '';
var key = await getOrCreateDeviceKey(db);
return await decryptPassword(key, record.encrypted);
} catch (_) {
return '';
} finally {
db.close();
}
}
async function clearPeerPassword(deviceId) {
if (!deviceId || !window.indexedDB) return;
var db = await openDb();
try {
await idbDelete(db, peerKey(deviceId));
} finally {
db.close();
}
}
async function clearAllPeerPasswords() {
if (!window.indexedDB) return;
var db = await openDb();
try {
var tx = db.transaction(STORE, 'readwrite');
var store = tx.objectStore(STORE);
var req = store.openCursor();
await new Promise(function (resolve, reject) {
req.onsuccess = function () {
var cursor = req.result;
if (!cursor) { resolve(); return; }
if (String(cursor.key).indexOf('peer:') === 0) {
cursor.delete();
}
cursor.continue();
};
req.onerror = function () { reject(req.error); };
});
} finally {
db.close();
}
}
return { return {
saveCredentials: saveCredentials, saveCredentials: saveCredentials,
loadCredentials: loadCredentials, loadCredentials: loadCredentials,
clearCredentials: clearCredentials, clearCredentials: clearCredentials,
clearStoredPassword: clearStoredPassword, clearStoredPassword: clearStoredPassword,
hasStoredPassword: hasStoredPassword, hasStoredPassword: hasStoredPassword,
loadLastUsername: loadLastUsername loadLastUsername: loadLastUsername,
savePeerPassword: savePeerPassword,
loadPeerPassword: loadPeerPassword,
clearPeerPassword: clearPeerPassword,
clearAllPeerPasswords: clearAllPeerPasswords
}; };
})(); })();
+51
View File
@@ -419,6 +419,7 @@
renderGrid(); renderGrid();
showState('grid'); showState('grid');
} }
syncDesktopViewport();
} }
function isRdClientDesktop() { function isRdClientDesktop() {
@@ -426,12 +427,36 @@
return !!(window.__TAURI__ && window.__TAURI__.core && typeof window.__TAURI__.core.invoke === 'function'); return !!(window.__TAURI__ && window.__TAURI__.core && typeof window.__TAURI__.core.invoke === 'function');
} }
function syncDesktopViewport() {
var h = window.innerHeight;
if (h < 1) return;
document.documentElement.style.setProperty('--rd-desk-vh', h + 'px');
}
function ensureSidebarScroll() {
var sidebar = document.getElementById('rd-desk-sidebar');
if (!sidebar || document.getElementById('rd-desk-sidebar-scroll')) return;
var scroll = document.createElement('div');
scroll.className = 'rd-desk-sidebar-scroll';
scroll.id = 'rd-desk-sidebar-scroll';
while (sidebar.firstChild) {
scroll.appendChild(sidebar.firstChild);
}
sidebar.appendChild(scroll);
}
function markDesktopLayout() { function markDesktopLayout() {
syncDesktopViewport();
ensureSidebarScroll();
if (!isRdClientDesktop()) return; if (!isRdClientDesktop()) return;
document.documentElement.classList.add('rd-desk-desktop'); document.documentElement.classList.add('rd-desk-desktop');
document.body.classList.add('rd-desk-desktop'); document.body.classList.add('rd-desk-desktop');
var app = document.getElementById('rd-desk-app'); var app = document.getElementById('rd-desk-app');
if (app) app.classList.add('rd-desk-desktop'); if (app) app.classList.add('rd-desk-desktop');
if (!window.__rdDeskViewportBound) {
window.__rdDeskViewportBound = true;
window.addEventListener('resize', syncDesktopViewport);
}
} }
function setDevicesPanelCollapsed(collapsed) { function setDevicesPanelCollapsed(collapsed) {
@@ -708,6 +733,30 @@
} }
} }
function populateLanguageSelect() {
var sel = document.getElementById('rd-desk-lang');
if (!sel || sel.options.length > 0) return;
var langs = (window.BetterDesk && window.BetterDesk.availableLanguages) || [];
langs.forEach(function (lang) {
var opt = document.createElement('option');
opt.value = lang.code;
opt.textContent = lang.native || lang.name || lang.code;
if (lang.code === (window.BetterDesk && window.BetterDesk.lang)) opt.selected = true;
sel.appendChild(opt);
});
}
function bindDesktopSettings() {
if (!isRdClientDesktop()) return;
var btn = document.getElementById('rd-desk-settings');
if (!btn) return;
btn.style.display = '';
btn.addEventListener('click', function () {
var invoke = window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke;
if (invoke) invoke('open_settings').catch(function () { /* ignore */ });
});
}
function startAutoRefresh() { function startAutoRefresh() {
if (refreshTimer) clearInterval(refreshTimer); if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(function () { loadAll(true); }, REFRESH_MS); refreshTimer = setInterval(function () { loadAll(true); }, REFRESH_MS);
@@ -715,6 +764,8 @@
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
markDesktopLayout(); markDesktopLayout();
populateLanguageSelect();
bindDesktopSettings();
renderBrandLogo(); renderBrandLogo();
renderUser(); renderUser();
bindUi(); bindUi();
+31
View File
@@ -138,6 +138,7 @@
this.connectionOverlay = panel.querySelector('.session-connection-overlay'); this.connectionOverlay = panel.querySelector('.session-connection-overlay');
this.passwordOverlay = panel.querySelector('.session-password-overlay'); this.passwordOverlay = panel.querySelector('.session-password-overlay');
this.passwordInput = panel.querySelector('.session-password-input'); this.passwordInput = panel.querySelector('.session-password-input');
this.rememberPeerCheckbox = panel.querySelector('.session-remember-peer-checkbox');
this.loginError = panel.querySelector('.session-login-error'); this.loginError = panel.querySelector('.session-login-error');
this.statusText = panel.querySelector('.session-status-text'); this.statusText = panel.querySelector('.session-status-text');
this.overlayActions = panel.querySelector('.session-overlay-actions'); this.overlayActions = panel.querySelector('.session-overlay-actions');
@@ -545,6 +546,14 @@
session.passwordOverlay.style.display = 'flex'; session.passwordOverlay.style.display = 'flex';
session.loginError.style.display = 'none'; session.loginError.style.display = 'none';
session.passwordInput.value = ''; session.passwordInput.value = '';
if (window.RdClientSecureStore && session.deviceId) {
window.RdClientSecureStore.loadPeerPassword(session.deviceId).then(function (saved) {
if (saved) {
session.passwordInput.value = saved;
if (session.rememberPeerCheckbox) session.rememberPeerCheckbox.checked = true;
}
}).catch(function () { /* ignore */ });
}
if (isActive(session)) session.passwordInput.focus(); if (isActive(session)) session.passwordInput.focus();
}); });
@@ -583,6 +592,14 @@
session.passwordOverlay.style.display = 'none'; session.passwordOverlay.style.display = 'none';
session.tfaOverlay.style.display = 'none'; session.tfaOverlay.style.display = 'none';
session.passwordInput.blur(); session.passwordInput.blur();
if (window.RdClientSecureStore && session.rememberPeerCheckbox) {
var pw = session.passwordInput.value;
if (session.rememberPeerCheckbox.checked && pw) {
window.RdClientSecureStore.savePeerPassword(session.deviceId, pw).catch(function () { /* ignore */ });
} else if (!session.rememberPeerCheckbox.checked) {
window.RdClientSecureStore.clearPeerPassword(session.deviceId).catch(function () { /* ignore */ });
}
}
}); });
c.on('session_start', () => { c.on('session_start', () => {
@@ -1708,7 +1725,21 @@
// ---- Initialize ---- // ---- Initialize ----
function populateViewerLanguageSelect() {
var sel = document.getElementById('viewer-language-select');
if (!sel || sel.options.length > 0) return;
var langs = (window.BetterDesk && window.BetterDesk.availableLanguages) || [];
langs.forEach(function (lang) {
var opt = document.createElement('option');
opt.value = lang.code;
opt.textContent = lang.native || lang.name || lang.code;
if (lang.code === (window.BetterDesk && window.BetterDesk.lang)) opt.selected = true;
sel.appendChild(opt);
});
}
function init() { function init() {
populateViewerLanguageSelect();
const deviceId = window.__initialDeviceId; const deviceId = window.__initialDeviceId;
const deviceName = window.__initialDeviceName || ''; const deviceName = window.__initialDeviceName || '';
if (deviceId) { if (deviceId) {
+14
View File
@@ -27,6 +27,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const crypto = require('crypto'); const crypto = require('crypto');
const config = require('../config/config');
const db = require('../services/database'); const db = require('../services/database');
const bdRelay = require('../services/bdRelay'); const bdRelay = require('../services/bdRelay');
const brandingService = require('../services/brandingService'); const brandingService = require('../services/brandingService');
@@ -147,6 +148,19 @@ function buildSessionHistory(entries, limit) {
.slice(0, limit); .slice(0, limit);
} }
// ---------------------------------------------------------------------------
// GET /api/bd/server-info — Public panel identity (RdClient URL validation)
// ---------------------------------------------------------------------------
router.get('/server-info', (_req, res) => {
res.json({
ok: true,
product: 'betterdesk-panel',
version: config.appVersion,
panel_name: config.appName,
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Middleware — authenticate desktop client via access token or session cookie // Middleware — authenticate desktop client via access token or session cookie
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+19 -6
View File
@@ -17,6 +17,7 @@ const { requireAuth, requireAdmin } = require('../middleware/auth');
const keyService = require('../services/keyService'); const keyService = require('../services/keyService');
const bundleService = require('../services/agentBundleService'); const bundleService = require('../services/agentBundleService');
const buildWorker = require('../services/agentBuildWorker'); const buildWorker = require('../services/agentBuildWorker');
const rdclientBuildWorker = require('../services/rdclientBuildWorker');
const db = require('../services/database'); const db = require('../services/database');
const config = require('../config/config'); const config = require('../config/config');
const brandingService = require('../services/brandingService'); const brandingService = require('../services/brandingService');
@@ -52,6 +53,7 @@ function serializeBundle(row) {
created_at: row.created_at, created_at: row.created_at,
updated_at: row.updated_at, updated_at: row.updated_at,
download_url: `/d/${publicId}`, download_url: `/d/${publicId}`,
product_type: row.product_type || 'agent',
}; };
} }
@@ -189,7 +191,11 @@ router.post('/api/generator/bundles', requireAuth, requireAdmin, async (req, res
if (!name) { if (!name) {
return res.status(400).json({ success: false, error: req.t('generator.errors.name_required') }); return res.status(400).json({ success: false, error: req.t('generator.errors.name_required') });
} }
const { valid, errors, normalized: base } = bundleService.validateBranding(req.body.branding || {}); const productType = String(req.body.product_type || 'agent').toLowerCase() === 'rdclient' ? 'rdclient' : 'agent';
const validateFn = productType === 'rdclient'
? bundleService.validateRdclientBranding
: bundleService.validateBranding;
const { valid, errors, normalized: base } = validateFn(req.body.branding || {});
if (!valid) { if (!valid) {
return res.status(400).json({ success: false, error: req.t('generator.errors.validation_failed'), errors, details: errors }); return res.status(400).json({ success: false, error: req.t('generator.errors.validation_failed'), errors, details: errors });
} }
@@ -207,9 +213,13 @@ router.post('/api/generator/bundles', requireAuth, requireAdmin, async (req, res
details: [slugResult.error], details: [slugResult.error],
}); });
} }
const normalized = finalizeBundleBranding(base); const normalized = productType === 'rdclient'
normalized.bundle_id = bundleId; ? { ...base, bundle_id: bundleId, server_url: base.panel_url }
normalized.product_name = normalized.company_name || 'BetterDesk Support'; : finalizeBundleBranding(base);
if (productType !== 'rdclient') {
normalized.bundle_id = bundleId;
normalized.product_name = normalized.company_name || 'BetterDesk Support';
}
const brandingHash = bundleService.hashBranding(normalized); const brandingHash = bundleService.hashBranding(normalized);
const created = await db.createAgentBundle({ const created = await db.createAgentBundle({
bundleId, bundleId,
@@ -218,9 +228,12 @@ router.post('/api/generator/bundles', requireAuth, requireAdmin, async (req, res
branding: JSON.stringify(normalized), branding: JSON.stringify(normalized),
brandingHash, brandingHash,
createdBy: req.session?.userId || null, createdBy: req.session?.userId || null,
productType,
}); });
// Phase 2: queue installer builds for every supported platform. const enqueue = productType === 'rdclient'
buildWorker.enqueueBuildsForHash(brandingHash).catch((e) => { ? rdclientBuildWorker.enqueueBuildsForHash
: buildWorker.enqueueBuildsForHash;
enqueue(brandingHash).catch((e) => {
console.error('[generator] enqueue builds failed:', e.message); console.error('[generator] enqueue builds failed:', e.message);
}); });
res.json({ success: true, data: { bundle: serializeBundle(created) } }); res.json({ success: true, data: { bundle: serializeBundle(created) } });
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const LANG_DIR = path.join(__dirname, '..', 'lang');
/** New keys per locale — English source + translations for all 26 locales. */
const PATCHES = {
en: {
remote: {
remember_peer_password: 'Remember device password on this device',
save_peer_password: 'Save password',
},
remote_dashboard: {
open_settings: 'Settings',
},
rdclient_settings: {
title: 'RdClient Settings',
open: 'Settings',
server_url: 'Panel URL',
tls_strict: 'Strict TLS',
sign_out: 'Sign out',
reset_client: 'Reset client',
discovery_refresh: 'Refresh',
discovery_empty: 'No servers found on the local network',
},
generator: {
rdclient_tab: 'RdClient desktop',
rdclient_new_bundle: 'New RdClient bundle',
rdclient_subtitle: 'Build branded RdClient desktop installers with embedded panel URL',
rdclient_server_url_hint: 'Public URL where operators reach the BetterDesk console (/remote dashboard).',
},
},
pl: {
remote: { remember_peer_password: 'Zapamiętaj hasło urządzenia na tym komputerze', save_peer_password: 'Zapisz hasło' },
remote_dashboard: { open_settings: 'Ustawienia' },
rdclient_settings: { title: 'Ustawienia RdClient', open: 'Ustawienia', server_url: 'URL panelu', tls_strict: 'Ścisłe TLS', sign_out: 'Wyloguj', reset_client: 'Resetuj klienta', discovery_refresh: 'Odśwież', discovery_empty: 'Nie znaleziono serwerów w sieci lokalnej' },
generator: { rdclient_tab: 'RdClient desktop', rdclient_new_bundle: 'Nowy pakiet RdClient', rdclient_subtitle: 'Buduj instalatory RdClient z wbudowanym adresem panelu', rdclient_server_url_hint: 'Publiczny URL panelu BetterDesk (dashboard /remote).' },
},
de: {
remote: { remember_peer_password: 'Gerätepasswort auf diesem Gerät merken', save_peer_password: 'Passwort speichern' },
remote_dashboard: { open_settings: 'Einstellungen' },
rdclient_settings: { title: 'RdClient-Einstellungen', open: 'Einstellungen', server_url: 'Panel-URL', tls_strict: 'Striktes TLS', sign_out: 'Abmelden', reset_client: 'Client zurücksetzen', discovery_refresh: 'Aktualisieren', discovery_empty: 'Keine Server im lokalen Netzwerk gefunden' },
generator: { rdclient_tab: 'RdClient Desktop', rdclient_new_bundle: 'Neues RdClient-Paket', rdclient_subtitle: 'Branded RdClient-Installer mit eingebetteter Panel-URL erstellen', rdclient_server_url_hint: 'Öffentliche URL der BetterDesk-Konsole (/remote).' },
},
fr: {
remote: { remember_peer_password: 'Mémoriser le mot de passe de lappareil sur cet ordinateur', save_peer_password: 'Enregistrer le mot de passe' },
remote_dashboard: { open_settings: 'Paramètres' },
rdclient_settings: { title: 'Paramètres RdClient', open: 'Paramètres', server_url: 'URL du panel', tls_strict: 'TLS strict', sign_out: 'Se déconnecter', reset_client: 'Réinitialiser le client', discovery_refresh: 'Actualiser', discovery_empty: 'Aucun serveur trouvé sur le réseau local' },
generator: { rdclient_tab: 'RdClient bureau', rdclient_new_bundle: 'Nouveau bundle RdClient', rdclient_subtitle: 'Créer des installateurs RdClient avec URL du panel intégrée', rdclient_server_url_hint: 'URL publique de la console BetterDesk (/remote).' },
},
es: {
remote: { remember_peer_password: 'Recordar contraseña del dispositivo en este equipo', save_peer_password: 'Guardar contraseña' },
remote_dashboard: { open_settings: 'Ajustes' },
rdclient_settings: { title: 'Ajustes de RdClient', open: 'Ajustes', server_url: 'URL del panel', tls_strict: 'TLS estricto', sign_out: 'Cerrar sesión', reset_client: 'Restablecer cliente', discovery_refresh: 'Actualizar', discovery_empty: 'No se encontraron servidores en la red local' },
generator: { rdclient_tab: 'RdClient escritorio', rdclient_new_bundle: 'Nuevo paquete RdClient', rdclient_subtitle: 'Generar instaladores RdClient con URL del panel integrada', rdclient_server_url_hint: 'URL pública de la consola BetterDesk (/remote).' },
},
it: {
remote: { remember_peer_password: 'Ricorda password dispositivo su questo computer', save_peer_password: 'Salva password' },
remote_dashboard: { open_settings: 'Impostazioni' },
rdclient_settings: { title: 'Impostazioni RdClient', open: 'Impostazioni', server_url: 'URL pannello', tls_strict: 'TLS rigoroso', sign_out: 'Esci', reset_client: 'Reimposta client', discovery_refresh: 'Aggiorna', discovery_empty: 'Nessun server trovato nella rete locale' },
generator: { rdclient_tab: 'RdClient desktop', rdclient_new_bundle: 'Nuovo bundle RdClient', rdclient_subtitle: 'Crea installer RdClient con URL pannello incorporato', rdclient_server_url_hint: 'URL pubblica della console BetterDesk (/remote).' },
},
pt: {
remote: { remember_peer_password: 'Lembrar senha do dispositivo neste computador', save_peer_password: 'Guardar senha' },
remote_dashboard: { open_settings: 'Definições' },
rdclient_settings: { title: 'Definições RdClient', open: 'Definições', server_url: 'URL do painel', tls_strict: 'TLS estrito', sign_out: 'Terminar sessão', reset_client: 'Repor cliente', discovery_refresh: 'Atualizar', discovery_empty: 'Nenhum servidor encontrado na rede local' },
generator: { rdclient_tab: 'RdClient desktop', rdclient_new_bundle: 'Novo pacote RdClient', rdclient_subtitle: 'Criar instaladores RdClient com URL do painel incorporado', rdclient_server_url_hint: 'URL pública da consola BetterDesk (/remote).' },
},
nl: {
remote: { remember_peer_password: 'Apparaatwachtwoord onthouden op dit apparaat', save_peer_password: 'Wachtwoord opslaan' },
remote_dashboard: { open_settings: 'Instellingen' },
rdclient_settings: { title: 'RdClient-instellingen', open: 'Instellingen', server_url: 'Paneel-URL', tls_strict: 'Strikte TLS', sign_out: 'Afmelden', reset_client: 'Client resetten', discovery_refresh: 'Vernieuwen', discovery_empty: 'Geen servers gevonden op het lokale netwerk' },
generator: { rdclient_tab: 'RdClient desktop', rdclient_new_bundle: 'Nieuw RdClient-pakket', rdclient_subtitle: 'RdClient-installers bouwen met ingebedde paneel-URL', rdclient_server_url_hint: 'Publieke URL van het BetterDesk-paneel (/remote).' },
},
cs: {
remote: { remember_peer_password: 'Zapamatovat heslo zařízení na tomto počítači', save_peer_password: 'Uložit heslo' },
remote_dashboard: { open_settings: 'Nastavení' },
rdclient_settings: { title: 'Nastavení RdClient', open: 'Nastavení', server_url: 'URL panelu', tls_strict: 'Přísné TLS', sign_out: 'Odhlásit', reset_client: 'Resetovat klienta', discovery_refresh: 'Obnovit', discovery_empty: 'V místní síti nebyly nalezeny žádné servery' },
generator: { rdclient_tab: 'RdClient desktop', rdclient_new_bundle: 'Nový balíček RdClient', rdclient_subtitle: 'Sestavit instalátory RdClient s vloženou URL panelu', rdclient_server_url_hint: 'Veřejná URL konzole BetterDesk (/remote).' },
},
da: {
remote: { remember_peer_password: 'Husk enhedsadgangskode på denne computer', save_peer_password: 'Gem adgangskode' },
remote_dashboard: { open_settings: 'Indstillinger' },
rdclient_settings: { title: 'RdClient-indstillinger', open: 'Indstillinger', server_url: 'Panel-URL', tls_strict: 'Streng TLS', sign_out: 'Log ud', reset_client: 'Nulstil klient', discovery_refresh: 'Opdater', discovery_empty: 'Ingen servere fundet på det lokale netværk' },
generator: { rdclient_tab: 'RdClient desktop', rdclient_new_bundle: 'Ny RdClient-pakke', rdclient_subtitle: 'Byg RdClient-installere med indlejret panel-URL', rdclient_server_url_hint: 'Offentlig URL til BetterDesk-konsollen (/remote).' },
},
fi: {
remote: { remember_peer_password: 'Muista laitteen salasana tällä tietokoneella', save_peer_password: 'Tallenna salasana' },
remote_dashboard: { open_settings: 'Asetukset' },
rdclient_settings: { title: 'RdClient-asetukset', open: 'Asetukset', server_url: 'Paneelin URL', tls_strict: 'Tiukka TLS', sign_out: 'Kirjaudu ulos', reset_client: 'Nollaa asiakas', discovery_refresh: 'Päivitä', discovery_empty: 'Paikallisesta verkosta ei löytynyt palvelimia' },
generator: { rdclient_tab: 'RdClient-työpöytä', rdclient_new_bundle: 'Uusi RdClient-paketti', rdclient_subtitle: 'Rakenna RdClient-asentajat upotetulla paneelin URL:llä', rdclient_server_url_hint: 'BetterDesk-konsolin julkinen URL (/remote).' },
},
nb: {
remote: { remember_peer_password: 'Husk enhetspassord på denne datamaskinen', save_peer_password: 'Lagre passord' },
remote_dashboard: { open_settings: 'Innstillinger' },
rdclient_settings: { title: 'RdClient-innstillinger', open: 'Innstillinger', server_url: 'Panel-URL', tls_strict: 'Streng TLS', sign_out: 'Logg ut', reset_client: 'Tilbakestill klient', discovery_refresh: 'Oppdater', discovery_empty: 'Ingen servere funnet på det lokale nettverket' },
generator: { rdclient_tab: 'RdClient desktop', rdclient_new_bundle: 'Nytt RdClient-pakke', rdclient_subtitle: 'Bygg RdClient-installere med innebygd panel-URL', rdclient_server_url_hint: 'Offentlig URL til BetterDesk-konsollen (/remote).' },
},
sv: {
remote: { remember_peer_password: 'Kom ihåg enhetslösenord på den här datorn', save_peer_password: 'Spara lösenord' },
remote_dashboard: { open_settings: 'Inställningar' },
rdclient_settings: { title: 'RdClient-inställningar', open: 'Inställningar', server_url: 'Panel-URL', tls_strict: 'Strikt TLS', sign_out: 'Logga ut', reset_client: 'Återställ klient', discovery_refresh: 'Uppdatera', discovery_empty: 'Inga servrar hittades i det lokala nätverket' },
generator: { rdclient_tab: 'RdClient desktop', rdclient_new_bundle: 'Nytt RdClient-paket', rdclient_subtitle: 'Bygg RdClient-installerare med inbäddad panel-URL', rdclient_server_url_hint: 'Offentlig URL till BetterDesk-konsolen (/remote).' },
},
ro: {
remote: { remember_peer_password: 'Memorează parola dispozitivului pe acest computer', save_peer_password: 'Salvează parola' },
remote_dashboard: { open_settings: 'Setări' },
rdclient_settings: { title: 'Setări RdClient', open: 'Setări', server_url: 'URL panou', tls_strict: 'TLS strict', sign_out: 'Deconectare', reset_client: 'Resetează clientul', discovery_refresh: 'Reîmprospătează', discovery_empty: 'Nu s-au găsit servere în rețeaua locală' },
generator: { rdclient_tab: 'RdClient desktop', rdclient_new_bundle: 'Pachet RdClient nou', rdclient_subtitle: 'Construiește instalatoare RdClient cu URL panou integrat', rdclient_server_url_hint: 'URL public al consolei BetterDesk (/remote).' },
},
hu: {
remote: { remember_peer_password: 'Eszköz jelszó megjegyzése ezen a gépen', save_peer_password: 'Jelszó mentése' },
remote_dashboard: { open_settings: 'Beállítások' },
rdclient_settings: { title: 'RdClient beállítások', open: 'Beállítások', server_url: 'Panel URL', tls_strict: 'Szigorú TLS', sign_out: 'Kijelentkezés', reset_client: 'Kliens visszaállítása', discovery_refresh: 'Frissítés', discovery_empty: 'Nem található szerver a helyi hálózaton' },
generator: { rdclient_tab: 'RdClient asztali', rdclient_new_bundle: 'Új RdClient csomag', rdclient_subtitle: 'RdClient telepítők készítése beágyazott panel URL-lel', rdclient_server_url_hint: 'A BetterDesk konzol nyilvános URL-je (/remote).' },
},
uk: {
remote: { remember_peer_password: 'Запам’ятати пароль пристрою на цьому комп’ютері', save_peer_password: 'Зберегти пароль' },
remote_dashboard: { open_settings: 'Налаштування' },
rdclient_settings: { title: 'Налаштування RdClient', open: 'Налаштування', server_url: 'URL панелі', tls_strict: 'Суворий TLS', sign_out: 'Вийти', reset_client: 'Скинути клієнт', discovery_refresh: 'Оновити', discovery_empty: 'Серверів у локальній мережі не знайдено' },
generator: { rdclient_tab: 'RdClient desktop', rdclient_new_bundle: 'Новий пакет RdClient', rdclient_subtitle: 'Збирати інсталятори RdClient із вбудованою URL панелі', rdclient_server_url_hint: 'Публічна URL консолі BetterDesk (/remote).' },
},
tr: {
remote: { remember_peer_password: 'Cihaz parolasını bu bilgisayarda hatırla', save_peer_password: 'Parolayı kaydet' },
remote_dashboard: { open_settings: 'Ayarlar' },
rdclient_settings: { title: 'RdClient ayarları', open: 'Ayarlar', server_url: 'Panel URL', tls_strict: 'Katı TLS', sign_out: 'Oturumu kapat', reset_client: 'İstemciyi sıfırla', discovery_refresh: 'Yenile', discovery_empty: 'Yerel ağda sunucu bulunamadı' },
generator: { rdclient_tab: 'RdClient masaüstü', rdclient_new_bundle: 'Yeni RdClient paketi', rdclient_subtitle: 'Gömülü panel URLli RdClient yükleyicileri oluştur', rdclient_server_url_hint: 'BetterDesk konsolunun genel URLsi (/remote).' },
},
ar: {
remote: { remember_peer_password: 'تذكر كلمة مرور الجهاز على هذا الحاسوب', save_peer_password: 'حفظ كلمة المرور' },
remote_dashboard: { open_settings: 'الإعدادات' },
rdclient_settings: { title: 'إعدادات RdClient', open: 'الإعدادات', server_url: 'رابط اللوحة', tls_strict: 'TLS صارم', sign_out: 'تسجيل الخروج', reset_client: 'إعادة ضبط العميل', discovery_refresh: 'تحديث', discovery_empty: 'لم يتم العثور على خوادم في الشبكة المحلية' },
generator: { rdclient_tab: 'RdClient سطح المكتب', rdclient_new_bundle: 'حزمة RdClient جديدة', rdclient_subtitle: 'إنشاء مثبتات RdClient مع رابط اللوحة المضمن', rdclient_server_url_hint: 'الرابط العام لوحة BetterDesk (/remote).' },
},
hi: {
remote: { remember_peer_password: 'इस डिवाइस पर डिवाइस पासवर्ड याद रखें', save_peer_password: 'पासवर्ड सहेजें' },
remote_dashboard: { open_settings: 'सेटिंग्स' },
rdclient_settings: { title: 'RdClient सेटिंग्स', open: 'सेटिंग्स', server_url: 'पैनल URL', tls_strict: 'सख्त TLS', sign_out: 'साइन आउट', reset_client: 'क्लाइंट रीसेट करें', discovery_refresh: 'रीफ़्रेश', discovery_empty: 'स्थानीय नेटवर्क पर कोई सर्वर नहीं मिला' },
generator: { rdclient_tab: 'RdClient डेस्कटॉप', rdclient_new_bundle: 'नया RdClient बंडल', rdclient_subtitle: 'एम्बेडेड पैनल URL के साथ RdClient इंस्टॉलर बनाएं', rdclient_server_url_hint: 'BetterDesk कंसोल का सार्वजनिक URL (/remote).' },
},
ja: {
remote: { remember_peer_password: 'このデバイスでデバイスパスワードを記憶', save_peer_password: 'パスワードを保存' },
remote_dashboard: { open_settings: '設定' },
rdclient_settings: { title: 'RdClient 設定', open: '設定', server_url: 'パネル URL', tls_strict: '厳格 TLS', sign_out: 'サインアウト', reset_client: 'クライアントをリセット', discovery_refresh: '更新', discovery_empty: 'ローカルネットワークにサーバーが見つかりません' },
generator: { rdclient_tab: 'RdClient デスクトップ', rdclient_new_bundle: '新規 RdClient バンドル', rdclient_subtitle: 'パネル URL を埋め込んだ RdClient インストーラーをビルド', rdclient_server_url_hint: 'BetterDesk コンソールの公開 URL (/remote)。' },
},
ko: {
remote: { remember_peer_password: '이 기기에서 장치 비밀번호 기억', save_peer_password: '비밀번호 저장' },
remote_dashboard: { open_settings: '설정' },
rdclient_settings: { title: 'RdClient 설정', open: '설정', server_url: '패널 URL', tls_strict: '엄격 TLS', sign_out: '로그아웃', reset_client: '클라이언트 재설정', discovery_refresh: '새로고침', discovery_empty: '로컬 네트워크에서 서버를 찾을 수 없습니다' },
generator: { rdclient_tab: 'RdClient 데스크톱', rdclient_new_bundle: '새 RdClient 번들', rdclient_subtitle: '패널 URL이 포함된 RdClient 설치 프로그램 빌드', rdclient_server_url_hint: 'BetterDesk 콘솔 공개 URL (/remote).' },
},
zh: {
remote: { remember_peer_password: '在此设备上记住设备密码', save_peer_password: '保存密码' },
remote_dashboard: { open_settings: '设置' },
rdclient_settings: { title: 'RdClient 设置', open: '设置', server_url: '面板 URL', tls_strict: '严格 TLS', sign_out: '退出登录', reset_client: '重置客户端', discovery_refresh: '刷新', discovery_empty: '在本地网络中未找到服务器' },
generator: { rdclient_tab: 'RdClient 桌面版', rdclient_new_bundle: '新建 RdClient 包', rdclient_subtitle: '构建嵌入面板 URL 的 RdClient 安装程序', rdclient_server_url_hint: 'BetterDesk 控制台的公开 URL (/remote)。' },
},
'zh-TW': {
remote: { remember_peer_password: '在此裝置上記住裝置密碼', save_peer_password: '儲存密碼' },
remote_dashboard: { open_settings: '設定' },
rdclient_settings: { title: 'RdClient 設定', open: '設定', server_url: '面板 URL', tls_strict: '嚴格 TLS', sign_out: '登出', reset_client: '重設用戶端', discovery_refresh: '重新整理', discovery_empty: '區域網路中找不到伺服器' },
generator: { rdclient_tab: 'RdClient 桌面版', rdclient_new_bundle: '新增 RdClient 套件', rdclient_subtitle: '建置內嵌面板 URL 的 RdClient 安裝程式', rdclient_server_url_hint: 'BetterDesk 主控台的公開 URL (/remote)。' },
},
vi: {
remote: { remember_peer_password: 'Ghi nhớ mật khẩu thiết bị trên máy này', save_peer_password: 'Lưu mật khẩu' },
remote_dashboard: { open_settings: 'Cài đặt' },
rdclient_settings: { title: 'Cài đặt RdClient', open: 'Cài đặt', server_url: 'URL bảng điều khiển', tls_strict: 'TLS nghiêm ngặt', sign_out: 'Đăng xuất', reset_client: 'Đặt lại client', discovery_refresh: 'Làm mới', discovery_empty: 'Không tìm thấy máy chủ trên mạng cục bộ' },
generator: { rdclient_tab: 'RdClient desktop', rdclient_new_bundle: 'Gói RdClient mới', rdclient_subtitle: 'Tạo trình cài RdClient với URL bảng điều khiển nhúng', rdclient_server_url_hint: 'URL công khai của bảng điều khiển BetterDesk (/remote).' },
},
th: {
remote: { remember_peer_password: 'จดจำรหัสผ่านอุปกรณ์บนคอมพิวเตอร์นี้', save_peer_password: 'บันทึกรหัสผ่าน' },
remote_dashboard: { open_settings: 'การตั้งค่า' },
rdclient_settings: { title: 'การตั้งค่า RdClient', open: 'การตั้งค่า', server_url: 'URL แผงควบคุม', tls_strict: 'TLS เข้มงวด', sign_out: 'ออกจากระบบ', reset_client: 'รีเซ็ตไคลเอนต์', discovery_refresh: 'รีเฟรช', discovery_empty: 'ไม่พบเซิร์ฟเวอร์ในเครือข่ายท้องถิ่น' },
generator: { rdclient_tab: 'RdClient เดสก์ท็อป', rdclient_new_bundle: 'ชุด RdClient ใหม่', rdclient_subtitle: 'สร้างตัวติดตั้ง RdClient พร้อม URL แผงควบคุมฝังตัว', rdclient_server_url_hint: 'URL สาธารณะของคอนโซล BetterDesk (/remote)' },
},
id: {
remote: { remember_peer_password: 'Ingat kata sandi perangkat di komputer ini', save_peer_password: 'Simpan kata sandi' },
remote_dashboard: { open_settings: 'Pengaturan' },
rdclient_settings: { title: 'Pengaturan RdClient', open: 'Pengaturan', server_url: 'URL panel', tls_strict: 'TLS ketat', sign_out: 'Keluar', reset_client: 'Reset klien', discovery_refresh: 'Segarkan', discovery_empty: 'Tidak ada server di jaringan lokal' },
generator: { rdclient_tab: 'RdClient desktop', rdclient_new_bundle: 'Paket RdClient baru', rdclient_subtitle: 'Buat installer RdClient dengan URL panel tertanam', rdclient_server_url_hint: 'URL publik konsol BetterDesk (/remote).' },
},
};
function deepMerge(target, patch) {
for (const [k, v] of Object.entries(patch)) {
if (v && typeof v === 'object' && !Array.isArray(v)) {
if (!target[k] || typeof target[k] !== 'object') target[k] = {};
deepMerge(target[k], v);
} else {
target[k] = v;
}
}
}
const files = fs.readdirSync(LANG_DIR).filter((f) => f.endsWith('.json'));
for (const file of files) {
const code = file.replace(/\.json$/, '');
const patch = PATCHES[code] || PATCHES.en;
const filePath = path.join(LANG_DIR, file);
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
deepMerge(data, patch);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
console.log('patched', file);
}
+14
View File
@@ -526,6 +526,12 @@ async function startServer() {
// Start LAN Discovery UDP service // Start LAN Discovery UDP service
startDiscoveryService(); startDiscoveryService();
try {
const panelDiscovery = require('./services/panelDiscovery');
panelDiscovery.startPanelMdns();
} catch (err) {
console.warn('[server] mDNS panel discovery disabled:', err.message);
}
// Start branded agent installer build worker (Generator Agenta / Phase 2). // Start branded agent installer build worker (Generator Agenta / Phase 2).
// Disabled when AGENT_BUILD_WORKER=off — useful for hosts without the // Disabled when AGENT_BUILD_WORKER=off — useful for hosts without the
@@ -538,6 +544,14 @@ async function startServer() {
console.warn('[server] agent build worker disabled:', err.message); console.warn('[server] agent build worker disabled:', err.message);
} }
} }
if (process.env.RDCLIENT_BUILD_WORKER !== 'off') {
try {
const rdclientBuildWorker = require('./services/rdclientBuildWorker');
rdclientBuildWorker.startWorker();
} catch (err) {
console.warn('[server] rdclient build worker disabled:', err.message);
}
}
// ============ RustDesk Client API (WAN :21121 → Go :21114 proxy) ============ // ============ RustDesk Client API (WAN :21121 → Go :21114 proxy) ============
let apiServer = null; let apiServer = null;
+3
View File
@@ -592,6 +592,8 @@ async function _claimNextBuild() {
return String(a.created_at || '').localeCompare(String(b.created_at || '')); return String(a.created_at || '').localeCompare(String(b.created_at || ''));
}); });
for (const row of candidates) { for (const row of candidates) {
const bundleRow = await _findBundleForHash(row.branding_hash);
if (bundleRow && (bundleRow.product_type || 'agent') === 'rdclient') continue;
const profile = BUILD_PROFILES[`${row.platform}/${row.arch}/${row.format}`]; const profile = BUILD_PROFILES[`${row.platform}/${row.arch}/${row.format}`];
if (!profile) continue; if (!profile) continue;
await db.upsertAgentBundleBuild({ await db.upsertAgentBundleBuild({
@@ -615,6 +617,7 @@ async function _listPendingBuilds(limit) {
const out = []; const out = [];
for (const b of bundles) { for (const b of bundles) {
if (b.revoked) continue; if (b.revoked) continue;
if ((b.product_type || 'agent') === 'rdclient') continue;
const builds = await db.listAgentBundleBuildsForHash(b.branding_hash); const builds = await db.listAgentBundleBuildsForHash(b.branding_hash);
for (const r of builds) { for (const r of builds) {
if (r.status === 'pending') out.push(r); if (r.status === 'pending') out.push(r);
+35
View File
@@ -18,6 +18,7 @@
'use strict'; 'use strict';
const crypto = require('crypto'); const crypto = require('crypto');
const config = require('../config/config');
const conn = require('./agentBundleConnection'); const conn = require('./agentBundleConnection');
// Supported delivery targets. The portal renders one card per entry. // Supported delivery targets. The portal renders one card per entry.
@@ -343,6 +344,39 @@ function defaultBranding() {
}; };
} }
/**
* Validate branding for RdClient desktop bundles (panel URL embedded in installer).
*/
function validateRdclientBranding(input = {}) {
const errors = [];
const out = {};
out.company_name = clip(input.company_name || input.companyName || 'BetterDesk RdClient', MAX_NAME);
out.server_host = clip(input.server_host || input.serverHost, 253);
out.use_https = input.use_https !== false && input.useHttps !== false;
const hostNorm = conn.normalizeServerHost(out.server_host);
if (!hostNorm.valid) {
errors.push(hostNorm.error || 'server_host_invalid');
} else {
out.server_host = hostNorm.host;
}
const scheme = out.use_https ? 'https' : 'http';
const port = config.port;
const omitPort = (scheme === 'https' && port === 443) || (scheme === 'http' && port === 80);
out.panel_url = omitPort
? `${scheme}://${out.server_host}`
: `${scheme}://${out.server_host}:${port}`;
out.default_lang = clip(input.default_lang || input.defaultLang || 'en', 10);
if (!SUPPORTED_LANGS.includes(out.default_lang)) {
out.default_lang = 'en';
}
return { valid: errors.length === 0, errors, normalized: out };
}
module.exports = { module.exports = {
PLATFORMS, PLATFORMS,
SUPPORTED_LANGS, SUPPORTED_LANGS,
@@ -350,6 +384,7 @@ module.exports = {
MAX_SLUG_LENGTH, MAX_SLUG_LENGTH,
MIN_SLUG_LENGTH, MIN_SLUG_LENGTH,
validateBranding, validateBranding,
validateRdclientBranding,
hashBranding, hashBranding,
generateBundleId, generateBundleId,
slugifyName, slugifyName,
+13 -4
View File
@@ -920,6 +920,15 @@ function createSqliteAdapter(config) {
CREATE INDEX IF NOT EXISTS idx_agent_bundle_builds_status ON agent_bundle_builds (status); CREATE INDEX IF NOT EXISTS idx_agent_bundle_builds_status ON agent_bundle_builds (status);
`); `);
migrateAgentBundleSlugsSqlite(db); migrateAgentBundleSlugsSqlite(db);
try {
const cols = new Set(db.prepare('PRAGMA table_info(agent_bundles)').all().map(c => c.name));
if (!cols.has('product_type')) {
db.exec("ALTER TABLE agent_bundles ADD COLUMN product_type TEXT NOT NULL DEFAULT 'agent'");
console.log('[DB] Migration: added agent_bundles.product_type');
}
} catch (e) {
console.warn('[DB] Migration agent_bundles.product_type error:', e.message);
}
} }
// -- Multi-tenancy tables ---------------------------------------------- // -- Multi-tenancy tables ----------------------------------------------
@@ -3074,12 +3083,12 @@ function createSqliteAdapter(config) {
return excludeBundleId ? row.bundle_id !== excludeBundleId : true; return excludeBundleId ? row.bundle_id !== excludeBundleId : true;
}, },
async createAgentBundle({ bundleId, slug, name, branding, brandingHash, createdBy }) { async createAgentBundle({ bundleId, slug, name, branding, brandingHash, createdBy, productType }) {
const db = openMain(); const db = openMain();
const r = db.prepare(` const r = db.prepare(`
INSERT INTO agent_bundles (bundle_id, slug, name, branding, branding_hash, created_by) INSERT INTO agent_bundles (bundle_id, slug, name, branding, branding_hash, created_by, product_type)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(bundleId, slug || null, name, branding, brandingHash, createdBy || null); `).run(bundleId, slug || null, name, branding, brandingHash, createdBy || null, productType || 'agent');
return db.prepare('SELECT * FROM agent_bundles WHERE id = ?').get(r.lastInsertRowid); return db.prepare('SELECT * FROM agent_bundles WHERE id = ?').get(r.lastInsertRowid);
}, },
+8 -1
View File
@@ -49,6 +49,12 @@ function buildAnnouncement() {
} }
} }
const host = process.env.PANEL_PUBLIC_HOST || config.host || 'localhost';
const port = config.port;
const protocol = config.httpsEnabled ? 'https' : 'http';
const panelUrl = process.env.PANEL_PUBLIC_URL
|| `${protocol}://${host}${(protocol === 'https' && port === 443) || (protocol === 'http' && port === 80) ? '' : `:${port}`}`;
return { return {
type: 'betterdesk-announce', type: 'betterdesk-announce',
version: PROTOCOL_VERSION, version: PROTOCOL_VERSION,
@@ -57,10 +63,11 @@ function buildAnnouncement() {
version: config.appVersion, version: config.appVersion,
port: config.port, port: config.port,
apiPort: config.apiPort, apiPort: config.apiPort,
protocol: config.httpsEnabled ? 'https' : 'http', protocol,
publicKey, publicKey,
addresses, addresses,
discoveryPort: DISCOVERY_PORT, discoveryPort: DISCOVERY_PORT,
panelUrl,
}, },
}; };
} }
+84
View File
@@ -0,0 +1,84 @@
/**
* BetterDesk Console mDNS panel discovery (optional)
*
* Publishes `_betterdesk._tcp` on the LAN so RdClient desktop can browse
* for panels. Disable with PANEL_MDNS=off.
*
* UDP LAN discovery remains in lanDiscovery.js (port 21119).
*
* @module services/panelDiscovery
*/
'use strict';
const config = require('../config/config');
const { buildAnnouncement } = require('./lanDiscovery');
let bonjourInstance = null;
let published = null;
function panelPublicUrl() {
const ann = buildAnnouncement();
return ann.server.panelUrl || null;
}
function startPanelMdns() {
if (process.env.PANEL_MDNS === 'off' || process.env.PANEL_MDNS === '0') {
return null;
}
let Bonjour;
try {
Bonjour = require('bonjour-service').Bonjour;
} catch (_) {
console.warn('[panelDiscovery] bonjour-service not installed — mDNS disabled (UDP discovery still active)');
return null;
}
if (bonjourInstance) return bonjourInstance;
const panelUrl = panelPublicUrl();
if (!panelUrl) return null;
let parsed;
try {
parsed = new URL(panelUrl);
} catch (_) {
return null;
}
const port = parsed.port
? parseInt(parsed.port, 10)
: (parsed.protocol === 'https:' ? 443 : 80);
bonjourInstance = new Bonjour();
published = bonjourInstance.publish({
name: config.appName || 'BetterDesk',
type: 'betterdesk',
protocol: 'tcp',
port,
txt: {
url: panelUrl.replace(/\/$/, ''),
version: String(config.appVersion || ''),
},
});
console.log(` mDNS discovery published _betterdesk._tcp port ${port} (${panelUrl})`);
return bonjourInstance;
}
function stopPanelMdns() {
if (published) {
try { published.stop(); } catch (_) { /* ignore */ }
published = null;
}
if (bonjourInstance) {
try { bonjourInstance.destroy(); } catch (_) { /* ignore */ }
bonjourInstance = null;
}
}
module.exports = {
startPanelMdns,
stopPanelMdns,
};
+346
View File
@@ -0,0 +1,346 @@
/**
* BetterDesk Console RdClient desktop build worker (Tauri)
*
* Builds branded RdClient installers when generator bundles have product_type=rdclient.
*/
'use strict';
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process');
const db = require('./database');
const bundleService = require('./agentBundleService');
const config = require('../config/config');
try {
const envFile = process.env.BETTERDESK_BUILD_ENV_FILE || '/etc/betterdesk/build.env';
if (fs.existsSync(envFile)) {
const txt = fs.readFileSync(envFile, 'utf8');
for (const line of txt.split(/\r?\n/)) {
const m = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
if (!m) continue;
const [, key, val] = m;
if (process.env[key] === undefined) process.env[key] = val;
}
}
} catch (e) {
console.warn('[rdclientBuildWorker] could not load build env file:', e.message);
}
const BUILD_CACHE_DIR = process.env.BUILD_CACHE_DIR
|| path.join(config.dataDir || '/opt/BetterDeskConsole/data', 'build-cache');
const WORK_ROOT = path.join(BUILD_CACHE_DIR, 'rdclient-work');
const ARTIFACT_ROOT = process.env.RDCLIENT_ARTIFACT_DIR
|| path.join(config.dataDir || '/opt/BetterDeskConsole/data', 'rdclient-builds');
const POLL_INTERVAL_MS = parseInt(process.env.RDCLIENT_BUILD_POLL_MS || '8000', 10);
const BUILD_TIMEOUT_MS = parseInt(process.env.RDCLIENT_BUILD_TIMEOUT_MS || (45 * 60 * 1000), 10);
const REPO_ROOT = path.resolve(__dirname, '..', '..');
const SOURCE_ROOT = path.join(REPO_ROOT, 'rdclient-desktop');
const BUILD_PROFILES = {
'windows/x64/portable': { os: 'windows', bundles: [], artifact: 'exe' },
'windows/x64/installed': { os: 'windows', bundles: ['msi'], artifact: 'msi' },
'linux/x64/portable': { os: 'linux', bundles: [], artifact: 'tgz' },
'linux/x64/appimage': { os: 'linux', bundles: ['appimage'], artifact: 'appimage' },
'linux/x64/installed': { os: 'linux', bundles: ['deb'], artifact: 'deb' },
'linux/x64/rpm': { os: 'linux', bundles: ['rpm'], artifact: 'rpm' },
};
let _pollTimer = null;
let _running = false;
let _activeBuilds = 0;
function _run(cmd, args, opts = {}) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd: opts.cwd,
env: { ...process.env, ...(opts.env || {}) },
stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '';
child.stdout.on('data', (d) => { out += d.toString(); });
child.stderr.on('data', (d) => { out += d.toString(); });
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`timeout after ${BUILD_TIMEOUT_MS}ms`));
}, BUILD_TIMEOUT_MS);
child.on('error', (e) => { clearTimeout(timer); reject(e); });
child.on('close', (code) => {
clearTimeout(timer);
if (code === 0) resolve(out);
else reject(new Error(`${cmd} exited ${code}: ${out.slice(-4000)}`));
});
});
}
async function _copyDir(src, dest) {
await fsp.mkdir(dest, { recursive: true });
const entries = await fsp.readdir(src, { withFileTypes: true });
for (const ent of entries) {
const s = path.join(src, ent.name);
const d = path.join(dest, ent.name);
if (ent.name === 'node_modules' || ent.name === 'target') continue;
if (ent.isDirectory()) await _copyDir(s, d);
else await fsp.copyFile(s, d);
}
}
async function _findBundleForHash(hash) {
const all = await db.listAgentBundles({ includeRevoked: true });
return all.find((b) => b.branding_hash === hash) || null;
}
async function _listPendingRdclientBuilds(limit) {
const bundles = await db.listAgentBundles();
const out = [];
for (const b of bundles) {
if (b.revoked || (b.product_type || 'agent') !== 'rdclient') continue;
const builds = await db.listAgentBundleBuildsForHash(b.branding_hash);
for (const r of builds) {
if (r.status === 'pending') out.push(r);
if (out.length >= limit) break;
}
if (out.length >= limit) break;
}
return out;
}
async function _hasRdclientBuildInProgress() {
if (_activeBuilds > 0) return true;
const bundles = await db.listAgentBundles();
for (const b of bundles) {
if ((b.product_type || 'agent') !== 'rdclient' || b.revoked) continue;
const builds = await db.listAgentBundleBuildsForHash(b.branding_hash);
if (builds.some((r) => r.status === 'building')) return true;
}
return false;
}
async function enqueueBuildsForHash(brandingHash, { force = false } = {}) {
const platforms = bundleService.PLATFORMS || [];
for (const p of platforms) {
const existing = await db.getAgentBundleBuild({
brandingHash, platform: p.platform, arch: p.arch, format: p.format,
});
if (!force && existing && (existing.status === 'ready' || existing.status === 'building')) {
continue;
}
await db.upsertAgentBundleBuild({
brandingHash,
platform: p.platform,
arch: p.arch,
format: p.format,
status: 'pending',
artifactPath: existing?.artifact_path || null,
artifactSize: existing?.artifact_size || 0,
artifactSha256: existing?.artifact_sha256 || null,
errorMessage: '',
});
}
}
async function _materialiseWorkDir(hash, branding) {
const workDir = path.join(WORK_ROOT, hash.slice(0, 16));
if (fs.existsSync(workDir)) {
await fsp.rm(workDir, { recursive: true, force: true });
}
await _copyDir(SOURCE_ROOT, workDir);
const embed = {
server_url: branding.panel_url || branding.server_url || '',
bundle_id: branding.bundle_id || '',
};
await fsp.writeFile(
path.join(workDir, 'betterdesk-rdclient.json'),
JSON.stringify(embed, null, 2),
'utf8'
);
return workDir;
}
async function _findArtifact(workDir, profile, key) {
const bundleDir = path.join(workDir, 'src-tauri', 'target', 'release', 'bundle');
const releaseDir = path.join(workDir, 'src-tauri', 'target', 'release');
if (profile.artifact === 'deb') {
const debDir = path.join(bundleDir, 'deb');
const files = fs.existsSync(debDir) ? await fsp.readdir(debDir) : [];
const deb = files.find((f) => f.endsWith('.deb'));
if (deb) return path.join(debDir, deb);
}
if (profile.artifact === 'rpm') {
const rpmDir = path.join(bundleDir, 'rpm');
const files = fs.existsSync(rpmDir) ? await fsp.readdir(rpmDir) : [];
const rpm = files.find((f) => f.endsWith('.rpm'));
if (rpm) return path.join(rpmDir, rpm);
}
if (profile.artifact === 'appimage') {
const aiDir = path.join(bundleDir, 'appimage');
const files = fs.existsSync(aiDir) ? await fsp.readdir(aiDir) : [];
const ai = files.find((f) => f.endsWith('.AppImage'));
if (ai) return path.join(aiDir, ai);
}
if (profile.artifact === 'msi') {
const msiDir = path.join(bundleDir, 'msi');
const files = fs.existsSync(msiDir) ? await fsp.readdir(msiDir) : [];
const msi = files.find((f) => f.endsWith('.msi'));
if (msi) return path.join(msiDir, msi);
}
if (profile.artifact === 'exe') {
const names = ['betterdesk-rdclient.exe', 'BetterDesk RdClient.exe', 'rdclient-desktop.exe'];
for (const n of names) {
const p = path.join(releaseDir, n);
if (fs.existsSync(p)) return p;
}
}
if (profile.artifact === 'tgz') {
const names = ['betterdesk-rdclient', 'BetterDesk RdClient', 'rdclient-desktop'];
for (const n of names) {
const bin = path.join(releaseDir, n);
if (fs.existsSync(bin)) {
const stage = path.join(workDir, 'dist-portable');
await fsp.mkdir(stage, { recursive: true });
await fsp.copyFile(bin, path.join(stage, n));
const launcher = path.join(SOURCE_ROOT, 'scripts', 'rdclient-launcher.sh');
if (fs.existsSync(launcher)) {
await fsp.copyFile(launcher, path.join(stage, 'rdclient-launcher.sh'));
await fsp.chmod(path.join(stage, 'rdclient-launcher.sh'), 0o755);
}
const tarPath = path.join(ARTIFACT_ROOT, `${key.replace(/\//g, '-')}.tar.gz`);
await fsp.mkdir(path.dirname(tarPath), { recursive: true });
await _run('tar', ['-czf', tarPath, '-C', stage, '.']);
return tarPath;
}
}
}
throw new Error(`artifact not found for ${key}`);
}
async function _sha256(filePath) {
return new Promise((resolve, reject) => {
const h = crypto.createHash('sha256');
const s = fs.createReadStream(filePath);
s.on('error', reject);
s.on('data', (d) => h.update(d));
s.on('end', () => resolve(h.digest('hex')));
});
}
async function _runOne(buildRow) {
const key = `${buildRow.platform}/${buildRow.arch}/${buildRow.format}`;
const profile = BUILD_PROFILES[key];
if (!profile) throw new Error(`unsupported profile ${key}`);
const bundleRow = await _findBundleForHash(buildRow.branding_hash);
if (!bundleRow || (bundleRow.product_type || 'agent') !== 'rdclient') {
throw new Error('not an rdclient bundle');
}
const branding = JSON.parse(bundleRow.branding || '{}');
if (!branding.panel_url && !branding.server_url) {
throw new Error('panel_url missing in rdclient bundle branding');
}
console.log(`[rdclientBuildWorker] build start ${key} hash=${buildRow.branding_hash.slice(0, 12)}`);
const workDir = await _materialiseWorkDir(buildRow.branding_hash, branding);
await _run('npm', ['ci'], { cwd: workDir });
const tauriArgs = ['run', 'tauri', 'build'];
if (profile.bundles.length) {
tauriArgs.push('--', '--bundles', profile.bundles.join(','));
}
await _run('npm', tauriArgs, { cwd: workDir });
const built = await _findArtifact(workDir, profile, key);
const destDir = path.join(ARTIFACT_ROOT, buildRow.branding_hash.slice(0, 16));
await fsp.mkdir(destDir, { recursive: true });
const destName = path.basename(built);
const destPath = path.join(destDir, destName);
if (built !== destPath) await fsp.copyFile(built, destPath);
const stat = await fsp.stat(destPath);
const sha = await _sha256(destPath);
await db.upsertAgentBundleBuild({
brandingHash: buildRow.branding_hash,
platform: buildRow.platform,
arch: buildRow.arch,
format: buildRow.format,
status: 'ready',
artifactPath: destPath,
artifactSize: stat.size,
artifactSha256: sha,
errorMessage: '',
});
console.log(`[rdclientBuildWorker] build ready ${key} (${(stat.size / 1024 / 1024).toFixed(2)} MB)`);
}
async function _tick() {
if (_running || _activeBuilds > 0) return;
if (await _hasRdclientBuildInProgress()) return;
_running = true;
try {
const pending = await _listPendingRdclientBuilds(1);
if (!pending.length) return;
const row = pending[0];
await db.upsertAgentBundleBuild({
brandingHash: row.branding_hash,
platform: row.platform,
arch: row.arch,
format: row.format,
status: 'building',
artifactPath: row.artifact_path || null,
artifactSize: row.artifact_size || 0,
artifactSha256: row.artifact_sha256 || null,
errorMessage: '',
});
_activeBuilds++;
try {
await _runOne(row);
} catch (e) {
const msg = e.message || String(e);
console.error(`[rdclientBuildWorker] build FAILED ${row.platform}/${row.format}: ${msg}`);
await db.upsertAgentBundleBuild({
brandingHash: row.branding_hash,
platform: row.platform,
arch: row.arch,
format: row.format,
status: 'failed',
artifactPath: null,
artifactSize: 0,
artifactSha256: null,
errorMessage: msg.slice(0, 2000),
});
} finally {
_activeBuilds--;
}
} finally {
_running = false;
}
}
function startWorker() {
if (_pollTimer) return;
if (!fs.existsSync(SOURCE_ROOT)) {
console.warn('[rdclientBuildWorker] rdclient-desktop source not found — worker disabled');
return;
}
fsp.mkdir(WORK_ROOT, { recursive: true }).catch(() => {});
fsp.mkdir(ARTIFACT_ROOT, { recursive: true }).catch(() => {});
_pollTimer = setInterval(() => {
_tick().catch((e) => console.error('[rdclientBuildWorker] tick error:', e.message));
}, POLL_INTERVAL_MS);
console.log(`[rdclientBuildWorker] started (poll ${POLL_INTERVAL_MS}ms)`);
}
function stopWorker() {
if (_pollTimer) clearInterval(_pollTimer);
_pollTimer = null;
}
module.exports = {
startWorker,
stopWorker,
enqueueBuildsForHash,
};
+4
View File
@@ -25,6 +25,10 @@
<span class="material-icons">add</span> <span class="material-icons">add</span>
${_('generator.new_bundle')} ${_('generator.new_bundle')}
</button> </button>
<button id="gen-new-rdclient" class="btn btn-secondary btn-sm" title="${_('generator.rdclient_new_bundle')}">
<span class="material-icons">connected_tv</span>
${_('generator.rdclient_tab')}
</button>
</div> </div>
<div class="card-body"> <div class="card-body">
<div id="gen-bundle-list" class="bundle-list"> <div id="gen-bundle-list" class="bundle-list">
+2 -1
View File
@@ -27,7 +27,8 @@
cacheVersion: '<%= cacheVersion %>', cacheVersion: '<%= cacheVersion %>',
csrfToken: '<%= typeof csrfToken !== "undefined" ? csrfToken : "" %>', csrfToken: '<%= typeof csrfToken !== "undefined" ? csrfToken : "" %>',
user: <%- JSON.stringify(user || null) %>, user: <%- JSON.stringify(user || null) %>,
branding: <%- JSON.stringify(branding || {}) %> branding: <%- JSON.stringify(branding || {}) %>,
availableLanguages: <%- JSON.stringify(availableLanguageList || []) %>
}; };
</script> </script>
<script src="/js/i18n-client.js?v=<%= cacheVersion %>"></script> <script src="/js/i18n-client.js?v=<%= cacheVersion %>"></script>
+3 -1
View File
@@ -29,7 +29,8 @@
csrfToken: '<%= typeof csrfToken !== 'undefined' ? csrfToken : '' %>', csrfToken: '<%= typeof csrfToken !== 'undefined' ? csrfToken : '' %>',
user: <%- JSON.stringify(user || null) %>, user: <%- JSON.stringify(user || null) %>,
serverPubKey: '<%= typeof serverPubKey !== 'undefined' ? serverPubKey : '' %>', serverPubKey: '<%= typeof serverPubKey !== 'undefined' ? serverPubKey : '' %>',
branding: <%- JSON.stringify(branding || {}) %> branding: <%- JSON.stringify(branding || {}) %>,
availableLanguages: <%- JSON.stringify(availableLanguageList || []) %>
}; };
</script> </script>
@@ -37,6 +38,7 @@
<!-- Translation helper --> <!-- Translation helper -->
<script src="/js/i18n-client.js?v=<%= cacheVersion %>"></script> <script src="/js/i18n-client.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclientSecureStore.js?v=<%= cacheVersion %>"></script>
<!-- Vendor libraries for remote client --> <!-- Vendor libraries for remote client -->
<script src="/js/vendor/protobuf.min.js?v=<%= cacheVersion %>"></script> <script src="/js/vendor/protobuf.min.js?v=<%= cacheVersion %>"></script>
+10 -1
View File
@@ -19,6 +19,14 @@
</div> </div>
<div class="rdclient-login-shell"> <div class="rdclient-login-shell">
<div class="rdclient-login-lang">
<span class="material-icons" aria-hidden="true">language</span>
<select id="rdclient-language-select" data-language-select aria-label="<%= _('settings.language') %>">
<% (availableLanguageList || []).forEach(function(language) { %>
<option value="<%= language.code %>" <%= lang === language.code ? 'selected' : '' %>><%= language.native || language.name || language.code %></option>
<% }); %>
</select>
</div>
<div class="rdclient-login-brand" id="rdclient-login-brand"> <div class="rdclient-login-brand" id="rdclient-login-brand">
<span class="material-icons">connected_tv</span> <span class="material-icons">connected_tv</span>
<span><%= appName %></span> <span><%= appName %></span>
@@ -114,7 +122,8 @@
translations: <%- JSON.stringify(translations || {}) %>, translations: <%- JSON.stringify(translations || {}) %>,
branding: <%- JSON.stringify(branding || {}) %>, branding: <%- JSON.stringify(branding || {}) %>,
returnUrl: <%- JSON.stringify(typeof returnUrl !== 'undefined' ? returnUrl : '/remote') %>, returnUrl: <%- JSON.stringify(typeof returnUrl !== 'undefined' ? returnUrl : '/remote') %>,
sessionExpired: <%= sessionExpired ? 'true' : 'false' %> sessionExpired: <%= sessionExpired ? 'true' : 'false' %>,
availableLanguages: <%- JSON.stringify(availableLanguageList || []) %>
}; };
</script> </script>
<script src="/js/i18n-client.js?v=<%= cacheVersion %>"></script> <script src="/js/i18n-client.js?v=<%= cacheVersion %>"></script>
+6
View File
@@ -13,6 +13,10 @@
</div> </div>
</div> </div>
<div class="rd-desk-header-actions"> <div class="rd-desk-header-actions">
<select id="rd-desk-lang" class="rd-desk-lang-select" data-language-select aria-label="${_('settings.language')}"></select>
<button type="button" class="rd-desk-icon-btn" id="rd-desk-settings" title="${_('rdclient_settings.open') || 'Settings'}" style="display:none;">
<span class="material-icons">settings</span>
</button>
<span class="rd-desk-user" id="rd-desk-user"></span> <span class="rd-desk-user" id="rd-desk-user"></span>
<button type="button" class="rd-desk-icon-btn" id="rd-desk-refresh" title="${_('remote_dashboard.sync_now')}"> <button type="button" class="rd-desk-icon-btn" id="rd-desk-refresh" title="${_('remote_dashboard.sync_now')}">
<span class="material-icons">sync</span> <span class="material-icons">sync</span>
@@ -39,6 +43,7 @@
<div class="rd-desk-workspace"> <div class="rd-desk-workspace">
<aside class="rd-desk-sidebar" id="rd-desk-sidebar"> <aside class="rd-desk-sidebar" id="rd-desk-sidebar">
<div class="rd-desk-sidebar-scroll" id="rd-desk-sidebar-scroll">
<nav class="rd-desk-nav-section" data-collapsible="folders"> <nav class="rd-desk-nav-section" data-collapsible="folders">
<button type="button" class="rd-desk-nav-heading rd-desk-nav-heading-btn" id="rd-desk-nav-folders-toggle" aria-expanded="true"> <button type="button" class="rd-desk-nav-heading rd-desk-nav-heading-btn" id="rd-desk-nav-folders-toggle" aria-expanded="true">
<span>${_('remote_dashboard.nav_folders')}</span> <span>${_('remote_dashboard.nav_folders')}</span>
@@ -84,6 +89,7 @@
</ul> </ul>
</div> </div>
</nav> </nav>
</div>
</aside> </aside>
<div class="rd-desk-content"> <div class="rd-desk-content">
+5
View File
@@ -32,6 +32,7 @@
<span class="toolbar-stats" id="toolbar-stats"></span> <span class="toolbar-stats" id="toolbar-stats"></span>
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<select id="viewer-language-select" class="toolbar-lang-select" data-language-select aria-label="${_('settings.language')}"></select>
<!-- Actions Menu --> <!-- Actions Menu -->
<div class="toolbar-dropdown"> <div class="toolbar-dropdown">
<button class="toolbar-btn" id="btn-actions" title="${_('remote.actions')}"> <button class="toolbar-btn" id="btn-actions" title="${_('remote.actions')}">
@@ -249,6 +250,10 @@
placeholder="${_('remote.password_placeholder')}" autocomplete="off"> placeholder="${_('remote.password_placeholder')}" autocomplete="off">
</div> </div>
<p class="login-error session-login-error" style="display:none;"></p> <p class="login-error session-login-error" style="display:none;"></p>
<label class="form-check session-remember-peer" style="display:flex;align-items:center;gap:8px;margin:10px 0 0;font-size:0.875rem;">
<input type="checkbox" class="session-remember-peer-checkbox">
<span>${_('remote.remember_peer_password') || 'Remember device password on this device'}</span>
</label>
<button class="btn btn-primary btn-full session-btn-authenticate"> <button class="btn btn-primary btn-full session-btn-authenticate">
<span class="material-icons">login</span> <span class="material-icons">login</span>
${_('remote.connect')} ${_('remote.connect')}