From 2e01955f6ca98baa17394970b794e26d3ea35794 Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:12:13 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 15 +- docs/PRE_RELEASE_CHECKLIST.md | 23 +- rdclient-desktop/README.md | 46 ++- rdclient-desktop/src-tauri/Cargo.lock | 332 ++++++++++++++++- rdclient-desktop/src-tauri/Cargo.toml | 3 + rdclient-desktop/src-tauri/build.rs | 9 + .../src-tauri/capabilities/default.json | 12 +- .../autogenerated/discover_servers.toml | 11 + .../permissions/autogenerated/get_config.toml | 11 + .../autogenerated/get_system_locale.toml | 11 + .../autogenerated/open_settings.toml | 11 + .../autogenerated/probe_server_url.toml | 11 + .../autogenerated/reset_client.toml | 11 + .../autogenerated/set_tls_strict.toml | 11 + .../autogenerated/set_ui_lang.toml | 11 + .../permissions/autogenerated/sign_out.toml | 11 + rdclient-desktop/src-tauri/src/config.rs | 77 +++- rdclient-desktop/src-tauri/src/discovery.rs | 114 ++++++ rdclient-desktop/src-tauri/src/lib.rs | 260 +++++++++++-- .../src-tauri/src/server_probe.rs | 111 ++++++ rdclient-desktop/src/settings.html | 122 ++++++ rdclient-desktop/src/settings.js | 137 +++++++ rdclient-desktop/src/setup.html | 70 ++-- rdclient-desktop/src/setup.js | 85 ++++- web-nodejs/lang/ar.json | 23 +- web-nodejs/lang/cs.json | 23 +- web-nodejs/lang/da.json | 23 +- web-nodejs/lang/de.json | 23 +- web-nodejs/lang/en.json | 23 +- web-nodejs/lang/es.json | 23 +- web-nodejs/lang/fi.json | 23 +- web-nodejs/lang/fr.json | 23 +- web-nodejs/lang/hi.json | 23 +- web-nodejs/lang/hu.json | 23 +- web-nodejs/lang/id.json | 23 +- web-nodejs/lang/it.json | 23 +- web-nodejs/lang/ja.json | 23 +- web-nodejs/lang/ko.json | 23 +- web-nodejs/lang/nb.json | 23 +- web-nodejs/lang/nl.json | 23 +- web-nodejs/lang/pl.json | 23 +- web-nodejs/lang/pt.json | 23 +- web-nodejs/lang/ro.json | 23 +- web-nodejs/lang/sv.json | 23 +- web-nodejs/lang/th.json | 23 +- web-nodejs/lang/tr.json | 23 +- web-nodejs/lang/uk.json | 23 +- web-nodejs/lang/vi.json | 23 +- web-nodejs/lang/zh-TW.json | 23 +- web-nodejs/lang/zh.json | 23 +- web-nodejs/public/css/remote-dashboard.css | 81 ++-- web-nodejs/public/js/generator.js | 13 +- web-nodejs/public/js/rdclient/cdap-adapter.js | 108 +++++- web-nodejs/public/js/rdclient/client.js | 33 ++ web-nodejs/public/js/rdclient/video.js | 257 ++++++++++++- web-nodejs/public/js/rdclientSecureStore.js | 75 +++- web-nodejs/public/js/remote-dashboard.js | 51 +++ web-nodejs/public/js/remote.js | 31 ++ web-nodejs/routes/bd-api.routes.js | 14 + web-nodejs/routes/generator.routes.js | 25 +- web-nodejs/scripts/patch-rdclient-i18n.js | 208 +++++++++++ web-nodejs/server.js | 14 + web-nodejs/services/agentBuildWorker.js | 3 + web-nodejs/services/agentBundleService.js | 35 ++ web-nodejs/services/dbAdapter.js | 17 +- web-nodejs/services/lanDiscovery.js | 9 +- web-nodejs/services/panelDiscovery.js | 84 +++++ web-nodejs/services/rdclientBuildWorker.js | 346 ++++++++++++++++++ web-nodejs/views/generator.ejs | 4 + web-nodejs/views/layouts/remote-desk.ejs | 3 +- web-nodejs/views/layouts/viewer.ejs | 4 +- web-nodejs/views/rdclient-login.ejs | 11 +- web-nodejs/views/remote-dashboard.ejs | 6 + web-nodejs/views/remote.ejs | 5 + 74 files changed, 3308 insertions(+), 242 deletions(-) create mode 100644 rdclient-desktop/src-tauri/permissions/autogenerated/discover_servers.toml create mode 100644 rdclient-desktop/src-tauri/permissions/autogenerated/get_config.toml create mode 100644 rdclient-desktop/src-tauri/permissions/autogenerated/get_system_locale.toml create mode 100644 rdclient-desktop/src-tauri/permissions/autogenerated/open_settings.toml create mode 100644 rdclient-desktop/src-tauri/permissions/autogenerated/probe_server_url.toml create mode 100644 rdclient-desktop/src-tauri/permissions/autogenerated/reset_client.toml create mode 100644 rdclient-desktop/src-tauri/permissions/autogenerated/set_tls_strict.toml create mode 100644 rdclient-desktop/src-tauri/permissions/autogenerated/set_ui_lang.toml create mode 100644 rdclient-desktop/src-tauri/permissions/autogenerated/sign_out.toml create mode 100644 rdclient-desktop/src-tauri/src/discovery.rs create mode 100644 rdclient-desktop/src-tauri/src/server_probe.rs create mode 100644 rdclient-desktop/src/settings.html create mode 100644 rdclient-desktop/src/settings.js create mode 100644 web-nodejs/scripts/patch-rdclient-i18n.js create mode 100644 web-nodejs/services/panelDiscovery.js create mode 100644 web-nodejs/services/rdclientBuildWorker.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 043c2d17..502b4341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,17 @@ ## [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 -- _(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 -- **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 (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. diff --git a/docs/PRE_RELEASE_CHECKLIST.md b/docs/PRE_RELEASE_CHECKLIST.md index 78264a50..f3083432 100644 --- a/docs/PRE_RELEASE_CHECKLIST.md +++ b/docs/PRE_RELEASE_CHECKLIST.md @@ -24,7 +24,18 @@ Use this checklist before every tagged release to ensure quality and stability. - [ ] **Login**: Admin login works, session created - [ ] **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` - [ ] **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 - [ ] **Single-instance**: second launch brings first to foreground -## 4. Agent Client (Tauri) +## 5. Agent Client (Tauri) - [ ] **Install deps**: `cd betterdesk-agent-client && pnpm install` - [ ] **Build**: `cargo tauri build` — NSIS produced - [ ] **Setup wizard**: 5-step onboarding completes successfully - [ ] **Registration**: device appears in server peer list -## 5. Native Agent (Go) +## 6. Native Agent (Go) - [ ] **Build**: `cd betterdesk-agent && go build -o betterdesk-agent .` - [ ] **Connection**: connects to CDAP gateway, manifests registers - [ ] **Heartbeat**: metrics flow (CPU / Memory / Disk) -## 6. Docker +## 7. Docker - [ ] **Build all images**: `docker compose build` — no errors - [ ] **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 - [ ] **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 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 - [ ] **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). diff --git a/rdclient-desktop/README.md b/rdclient-desktop/README.md index 6b9e0048..02b5bffe 100644 --- a/rdclient-desktop/README.md +++ b/rdclient-desktop/README.md @@ -2,7 +2,7 @@ Tauri v2 desktop shell for the RdClient operator UI. The app loads your panel’s **`/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 @@ -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. -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). 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 @@ -127,9 +146,12 @@ Installers/binaries are under `src-tauri/target/release/bundle/`. | Piece | Role | |-------|------| -| `src/setup.html` | First-run local page to save the panel URL | -| `src-tauri/src/lib.rs` | Tauri commands: `get_server_url`, `set_server_url`, `open_session` | -| `src-tauri/src/config.rs` | Persists `server_url` in the app config dir | +| `src/setup.html` | First-run: LAN discovery list + manual panel URL | +| `src/settings.html` | Local settings: URL, TLS, language, sign out, reset | +| `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/tls_policy.rs` | Windows WebView2 + strict-mode env | | `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 ``` -## 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`) - WebSocket relay Bearer auth for long-lived desktop sessions -- Settings → change server URL / sign out -- CI release artifacts (replacing legacy `betterdesk-mgmt`) +- Native Rust video decoder (if WebCodecs insufficient on Linux) ## License diff --git a/rdclient-desktop/src-tauri/Cargo.lock b/rdclient-desktop/src-tauri/Cargo.lock index cf7e3ca6..6f259489 100644 --- a/rdclient-desktop/src-tauri/Cargo.lock +++ b/rdclient-desktop/src-tauri/Cargo.lock @@ -307,6 +307,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.45" @@ -1017,8 +1023,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1028,9 +1036,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1302,6 +1312,22 @@ dependencies = [ "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]] name = "hyper-util" version = "0.1.20" @@ -1733,6 +1759,12 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "markup5ever" version = "0.38.0" @@ -2282,6 +2314,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "precomputed-hash" version = "0.1.1" @@ -2369,6 +2410,61 @@ dependencies = [ "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]] name = "quote" version = "1.0.45" @@ -2390,6 +2486,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "raw-window-handle" version = "0.6.2" @@ -2400,12 +2525,15 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" name = "rdclient-desktop" version = "0.1.0" dependencies = [ + "reqwest 0.12.28", "serde", "serde_json", + "sys-locale", "tauri", "tauri-build", "tauri-plugin-shell", "tauri-utils", + "tokio", ] [[package]] @@ -2477,6 +2605,44 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "reqwest" version = "0.13.4" @@ -2511,6 +2677,20 @@ dependencies = [ "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]] name = "rustc-hash" version = "2.1.2" @@ -2526,12 +2706,53 @@ dependencies = [ "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]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2722,6 +2943,18 @@ dependencies = [ "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]] name = "serde_with" version = "3.21.0" @@ -2962,6 +3195,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3014,6 +3253,15 @@ dependencies = [ "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]] name = "system-deps" version = "6.2.2" @@ -3114,7 +3362,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -3450,9 +3698,31 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "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]] name = "tokio-util" version = "0.7.18" @@ -3749,6 +4019,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -3985,6 +4261,16 @@ dependencies = [ "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]] name = "web_atoms" version = "0.2.4" @@ -4041,6 +4327,15 @@ dependencies = [ "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]] name = "webview2-com" version = "0.38.2" @@ -4271,6 +4566,15 @@ dependencies = [ "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]] name = "windows-sys" version = "0.59.0" @@ -4722,6 +5026,26 @@ dependencies = [ "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]] name = "zerofrom" version = "0.1.8" @@ -4743,6 +5067,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/rdclient-desktop/src-tauri/Cargo.toml b/rdclient-desktop/src-tauri/Cargo.toml index 5c666b8b..c45b75b5 100644 --- a/rdclient-desktop/src-tauri/Cargo.toml +++ b/rdclient-desktop/src-tauri/Cargo.toml @@ -18,6 +18,9 @@ tauri-plugin-shell = "2" tauri-utils = "2" serde = { version = "1", features = ["derive"] } 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. # wry: accept operator-panel TLS (self-signed / incomplete chain) on Linux WebKitGTK. diff --git a/rdclient-desktop/src-tauri/build.rs b/rdclient-desktop/src-tauri/build.rs index 5f7330e4..877cda20 100644 --- a/rdclient-desktop/src-tauri/build.rs +++ b/rdclient-desktop/src-tauri/build.rs @@ -4,7 +4,16 @@ fn main() { tauri_build::AppManifest::new().commands(&[ "get_client_info", "get_server_url", + "get_config", + "probe_server_url", + "discover_servers", "set_server_url", + "set_tls_strict", + "set_ui_lang", + "get_system_locale", + "open_settings", + "sign_out", + "reset_client", "open_session", "close_current_window", ]), diff --git a/rdclient-desktop/src-tauri/capabilities/default.json b/rdclient-desktop/src-tauri/capabilities/default.json index 25b9e769..d153bc8d 100644 --- a/rdclient-desktop/src-tauri/capabilities/default.json +++ b/rdclient-desktop/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2/capability", "identifier": "default", "description": "Default RdClient desktop capabilities", - "windows": ["main", "session-*"], + "windows": ["main", "settings", "session-*"], "remote": { "urls": [ "http://*", @@ -33,9 +33,19 @@ "core:window:allow-set-focus", "core:window:allow-close", "core:webview:allow-create-webview-window", + "core:webview:allow-clear-all-browsing-data", "shell:allow-open", "allow-get-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-close-current-window", "allow-get-client-info" diff --git a/rdclient-desktop/src-tauri/permissions/autogenerated/discover_servers.toml b/rdclient-desktop/src-tauri/permissions/autogenerated/discover_servers.toml new file mode 100644 index 00000000..ad24ff41 --- /dev/null +++ b/rdclient-desktop/src-tauri/permissions/autogenerated/discover_servers.toml @@ -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"] diff --git a/rdclient-desktop/src-tauri/permissions/autogenerated/get_config.toml b/rdclient-desktop/src-tauri/permissions/autogenerated/get_config.toml new file mode 100644 index 00000000..09a0ee27 --- /dev/null +++ b/rdclient-desktop/src-tauri/permissions/autogenerated/get_config.toml @@ -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"] diff --git a/rdclient-desktop/src-tauri/permissions/autogenerated/get_system_locale.toml b/rdclient-desktop/src-tauri/permissions/autogenerated/get_system_locale.toml new file mode 100644 index 00000000..d6c106c1 --- /dev/null +++ b/rdclient-desktop/src-tauri/permissions/autogenerated/get_system_locale.toml @@ -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"] diff --git a/rdclient-desktop/src-tauri/permissions/autogenerated/open_settings.toml b/rdclient-desktop/src-tauri/permissions/autogenerated/open_settings.toml new file mode 100644 index 00000000..6ee995b1 --- /dev/null +++ b/rdclient-desktop/src-tauri/permissions/autogenerated/open_settings.toml @@ -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"] diff --git a/rdclient-desktop/src-tauri/permissions/autogenerated/probe_server_url.toml b/rdclient-desktop/src-tauri/permissions/autogenerated/probe_server_url.toml new file mode 100644 index 00000000..ff7ff319 --- /dev/null +++ b/rdclient-desktop/src-tauri/permissions/autogenerated/probe_server_url.toml @@ -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"] diff --git a/rdclient-desktop/src-tauri/permissions/autogenerated/reset_client.toml b/rdclient-desktop/src-tauri/permissions/autogenerated/reset_client.toml new file mode 100644 index 00000000..c2864d73 --- /dev/null +++ b/rdclient-desktop/src-tauri/permissions/autogenerated/reset_client.toml @@ -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"] diff --git a/rdclient-desktop/src-tauri/permissions/autogenerated/set_tls_strict.toml b/rdclient-desktop/src-tauri/permissions/autogenerated/set_tls_strict.toml new file mode 100644 index 00000000..d5726e34 --- /dev/null +++ b/rdclient-desktop/src-tauri/permissions/autogenerated/set_tls_strict.toml @@ -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"] diff --git a/rdclient-desktop/src-tauri/permissions/autogenerated/set_ui_lang.toml b/rdclient-desktop/src-tauri/permissions/autogenerated/set_ui_lang.toml new file mode 100644 index 00000000..993be043 --- /dev/null +++ b/rdclient-desktop/src-tauri/permissions/autogenerated/set_ui_lang.toml @@ -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"] diff --git a/rdclient-desktop/src-tauri/permissions/autogenerated/sign_out.toml b/rdclient-desktop/src-tauri/permissions/autogenerated/sign_out.toml new file mode 100644 index 00000000..f4d3d0c6 --- /dev/null +++ b/rdclient-desktop/src-tauri/permissions/autogenerated/sign_out.toml @@ -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"] diff --git a/rdclient-desktop/src-tauri/src/config.rs b/rdclient-desktop/src-tauri/src/config.rs index cdb62ee4..71fb8454 100644 --- a/rdclient-desktop/src-tauri/src/config.rs +++ b/rdclient-desktop/src-tauri/src/config.rs @@ -4,11 +4,37 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Manager}; +pub const CONFIG_VERSION: u32 = 1; const CONFIG_FILE: &str = "config.json"; -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { + #[serde(default = "default_config_version")] + pub config_version: u32, + #[serde(default)] pub server_url: Option, + #[serde(default)] + pub tls_strict: bool, + #[serde(default)] + pub discovered_via: Option, + #[serde(default)] + pub ui_lang: Option, +} + +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 { @@ -26,11 +52,56 @@ pub fn load_config(app: &AppHandle) -> Result { return Ok(AppConfig::default()); } 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> { 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()) } + +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 { + 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 { + 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"); + } + } +} diff --git a/rdclient-desktop/src-tauri/src/discovery.rs b/rdclient-desktop/src-tauri/src/discovery.rs new file mode 100644 index 00000000..ea63ac08 --- /dev/null +++ b/rdclient-desktop/src-tauri/src/discovery.rs @@ -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, +} + +#[derive(Debug, Deserialize)] +struct AnnounceServer { + name: Option, + version: Option, + port: Option, + #[serde(rename = "apiPort")] + api_port: Option, + protocol: Option, + addresses: Option>, + #[serde(rename = "panelUrl")] + panel_url: Option, +} + +/// UDP LAN discovery using the existing BetterDesk console protocol. +pub fn discover_udp(timeout_ms: u64) -> Vec { + 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::(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 { + 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"); + } +} diff --git a/rdclient-desktop/src-tauri/src/lib.rs b/rdclient-desktop/src-tauri/src/lib.rs index 346e1924..3ca12d8b 100644 --- a/rdclient-desktop/src-tauri/src/lib.rs +++ b/rdclient-desktop/src-tauri/src/lib.rs @@ -1,22 +1,35 @@ mod config; +mod discovery; +mod server_probe; #[cfg(target_os = "linux")] pub mod linux_display; 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 tauri::ipc::CapabilityBuilder; use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; use tauri_utils::config::BackgroundThrottlingPolicy; 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). const DESKTOP_INIT_SCRIPT: &str = r#" 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 __rdIsSessionViewer(){return /\/remote\/[^/?#]+/.test(location.pathname);} function __rdCloseWindow(){ @@ -57,6 +70,21 @@ document.addEventListener('click',function(e){ }); } },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] = &[ @@ -65,15 +93,25 @@ const PANEL_INVOKE_PERMISSIONS: &[&str] = &[ "core:window:allow-set-focus", "core:window:allow-close", "core:webview:allow-create-webview-window", + "core:webview:allow-clear-all-browsing-data", "shell:allow-open", + "allow-get-client-info", "allow-get-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-close-current-window", - "allow-get-client-info", ]; -fn normalize_server_url(raw: &str) -> Result { +pub fn normalize_server_url(raw: &str) -> Result { let trimmed = raw.trim(); if trimmed.is_empty() { return Err("Server URL is required".into()); @@ -88,8 +126,7 @@ fn normalize_server_url(raw: &str) -> Result { format!("https://{trimmed}") }; - let parsed = url::parse_helper(&with_scheme)?; - Ok(parsed) + url::parse_helper(&with_scheme) } mod url { @@ -128,6 +165,7 @@ fn register_panel_remote_capability(app: &AppHandle, base: &str) -> Result<(), S let mut builder = CapabilityBuilder::new("panel-operator") .local(true) .window(MAIN_LABEL) + .window(SETTINGS_LABEL) .window("session-*"); for pattern in patterns { @@ -180,15 +218,57 @@ fn apply_desktop_window>( ) } +fn close_session_windows(app: &AppHandle) { + for label in app + .webview_windows() + .keys() + .filter(|l| l.starts_with("session-")) + .cloned() + .collect::>() + { + 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> { if let Some(existing) = app.get_webview_window(MAIN_LABEL) { if let WebviewUrl::External(parsed) = url { - existing - .navigate(parsed) - .map_err(|e| e.to_string())?; - existing - .set_focus() - .map_err(|e| e.to_string())?; + existing.navigate(parsed).map_err(|e| e.to_string())?; + existing.set_focus().map_err(|e| e.to_string())?; return Ok(()); } existing.close().map_err(|e| e.to_string())?; @@ -206,10 +286,47 @@ fn open_main_window(app: &AppHandle, url: WebviewUrl) -> Result<(), String> { Ok(()) } +fn try_auto_configure_server(app: &AppHandle) -> Result { + 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> { let cfg = load_config(app)?; - let url = if let Some(base) = cfg.server_url { - WebviewUrl::External(dashboard_url(&base)?) + apply_tls_from_config(&cfg); + + 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 { WebviewUrl::App("setup.html".into()) }; @@ -230,16 +347,104 @@ fn get_server_url(app: AppHandle) -> Result, String> { } #[tauri::command] -fn set_server_url(app: AppHandle, url: String) -> Result<(), String> { - let normalized = normalize_server_url(&url)?; - register_panel_remote_capability(&app, &normalized)?; +fn get_config(app: AppHandle) -> Result { + load_config(&app) +} + +#[tauri::command] +async fn probe_server_url(app: AppHandle, url: String) -> Result { + let cfg = load_config(&app)?; + Ok(probe_panel_url(&url, cfg.tls_strict).await) +} + +#[tauri::command] +fn discover_servers() -> Vec { + 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)?; - cfg.server_url = Some(normalized.clone()); + cfg.tls_strict = strict; save_config(&app, &cfg)?; - open_main_window( - &app, - WebviewUrl::External(dashboard_url(&normalized)?), + if strict { + unsafe { + 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) -> 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 { + 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] @@ -295,12 +500,21 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ get_client_info, get_server_url, + get_config, + probe_server_url, + discover_servers, set_server_url, + set_tls_strict, + set_ui_lang, + get_system_locale, + open_settings, + sign_out, + reset_client, open_session, close_current_window ]) .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)?; } launch_main(app.handle())?; diff --git a/rdclient-desktop/src-tauri/src/server_probe.rs b/rdclient-desktop/src-tauri/src/server_probe.rs new file mode 100644 index 00000000..f5a35eb3 --- /dev/null +++ b/rdclient-desktop/src-tauri/src/server_probe.rs @@ -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, + pub version: Option, + pub panel_name: Option, + pub error: Option, +} + +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::().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 { + 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()) +} diff --git a/rdclient-desktop/src/settings.html b/rdclient-desktop/src/settings.html new file mode 100644 index 00000000..37e775fa --- /dev/null +++ b/rdclient-desktop/src/settings.html @@ -0,0 +1,122 @@ + + + + + + BetterDesk RdClient — Settings + + + +
+

RdClient Settings

+

Local desktop preferences and account actions.

+ +
+

Server

+ + +
+ +
+
+ +
+

Security

+
+ + +
+

Only enable if your panel uses a trusted CA certificate. Self-signed panels require this to stay off.

+ +
+ +
+

Language

+ + +

Applied on next panel page load. Use the language menu in the dashboard for immediate change.

+
+ +
+

Session

+ +

Clears panel login cookies and opens the login page.

+
+ +
+

Reset

+ +

Removes server URL, cookies, saved passwords, and returns to setup.

+
+
+ + + diff --git a/rdclient-desktop/src/settings.js b/rdclient-desktop/src/settings.js new file mode 100644 index 00000000..8e44b09d --- /dev/null +++ b/rdclient-desktop/src/settings.js @@ -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(); +})(); diff --git a/rdclient-desktop/src/setup.html b/rdclient-desktop/src/setup.html index b6b2aaa0..48908f8e 100644 --- a/rdclient-desktop/src/setup.html +++ b/rdclient-desktop/src/setup.html @@ -29,29 +29,16 @@ } .card { width: 100%; - max-width: 440px; + max-width: 480px; padding: 28px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel); } - h1 { - margin: 0 0 8px; - font-size: 1.25rem; - font-weight: 600; - } - 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); - } + h1 { margin: 0 0 8px; font-size: 1.25rem; font-weight: 600; } + h2 { margin: 20px 0 8px; font-size: 0.875rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; } + p { margin: 0 0 16px; 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 { width: 100%; padding: 10px 12px; @@ -61,13 +48,8 @@ color: var(--text); font-size: 0.875rem; } - input:focus { - outline: none; - border-color: var(--accent); - } + input:focus { outline: none; border-color: var(--accent); } button { - margin-top: 16px; - width: 100%; padding: 10px 14px; border: none; border-radius: 8px; @@ -77,23 +59,47 @@ font-weight: 600; cursor: pointer; } + button.secondary { background: #21262d; border: 1px solid var(--border); color: var(--text); } button:disabled { opacity: 0.6; cursor: wait; } - .error { - margin-top: 12px; - color: var(--error); - font-size: 0.8125rem; - min-height: 1.2em; + .row { display: flex; gap: 8px; margin-top: 16px; } + .row button { flex: 1; } + .error { margin-top: 12px; color: var(--error); font-size: 0.8125rem; min-height: 1.2em; } + .discovered-list { list-style: none; margin: 0; padding: 0; } + .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; }

BetterDesk RdClient

-

Enter the URL of your BetterDesk console panel. The dashboard will load from /remote on this server.

+

Connect to your BetterDesk console panel. The dashboard loads from /remote on the server you choose.

+ +

Servers on your network

+
    +
  • Scanning…
  • +
+
+ +
+ +

Manual URL

- - + +
+ +
diff --git a/rdclient-desktop/src/setup.js b/rdclient-desktop/src/setup.js index 80403217..a18b8897 100644 --- a/rdclient-desktop/src/setup.js +++ b/rdclient-desktop/src/setup.js @@ -5,6 +5,9 @@ var input = document.getElementById('server-url'); var errorEl = document.getElementById('error'); 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) { if (errorEl) errorEl.textContent = msg || ''; @@ -17,34 +20,94 @@ return null; } - form.addEventListener('submit', async function (e) { - e.preventDefault(); - showError(''); - var url = (input.value || '').trim(); - if (!url) { - showError('Please enter a panel URL.'); - return; - } - + async function connectUrl(url) { var invoke = await getInvoke(); if (!invoke) { showError('Desktop bridge unavailable.'); return; } - submitBtn.disabled = true; + if (refreshBtn) refreshBtn.disabled = true; + showError(''); 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) { showError(String(err) || 'Failed to save server URL.'); 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 = '' + escapeHtml(s.name || s.url) + '' + + '' + escapeHtml(s.url) + ''; + 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, '&').replace(//g, '>'); + } + + 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) { if (!invoke) return; invoke('get_server_url').then(function (existing) { if (existing && input) input.value = existing; }).catch(function () { /* ignore */ }); + scanNetwork(); }); })(); diff --git a/web-nodejs/lang/ar.json b/web-nodejs/lang/ar.json index 380d6e27..2945a74c 100644 --- a/web-nodejs/lang/ar.json +++ b/web-nodejs/lang/ar.json @@ -745,7 +745,11 @@ "build_status_failed": "فشل", "builds_summary": "{{ready}} جاهز · {{pending}} في الانتظار · {{building}} جارٍ البناء · {{failed}} فشل", "build_error_hint": "خطأ في البناء", - "preview_help": "طلب المساعدة" + "preview_help": "طلب المساعدة", + "rdclient_tab": "RdClient سطح المكتب", + "rdclient_new_bundle": "حزمة RdClient جديدة", + "rdclient_subtitle": "إنشاء مثبتات RdClient مع رابط اللوحة المضمن", + "rdclient_server_url_hint": "الرابط العام لوحة BetterDesk (/remote)." }, "server": { "port": "Port", @@ -1051,7 +1055,9 @@ "file_loading": "تحميل...", "file_timeout": "الجهاز البعيد لم يستجب. قد لا يكون نقل الملفات مدعومًا أو معطلاً على الجهاز البعيد.", "codec": "ترميز الفيديو", - "codec_auto": "تلقائي (مستحسن)" + "codec_auto": "تلقائي (مستحسن)", + "remember_peer_password": "تذكر كلمة مرور الجهاز على هذا الحاسوب", + "save_peer_password": "حفظ كلمة المرور" }, "branding": { "identity_title": "هوية العلامة التجارية", @@ -3835,7 +3841,8 @@ "no_groups": "لا توجد مجموعات", "no_tags": "لا توجد وسوم", "collapse_devices": "طي قائمة الأجهزة", - "expand_devices": "توسيع قائمة الأجهزة" + "expand_devices": "توسيع قائمة الأجهزة", + "open_settings": "الإعدادات" }, "rdclient_login": { "title": "عميل سطح المكتب البعيد", @@ -3855,5 +3862,15 @@ "network_error": "خطأ في الشبكة", "enter_6_digits": "أدخل جميع الأرقام الستة", "invalid_code": "رمز غير صالح" + }, + "rdclient_settings": { + "title": "إعدادات RdClient", + "open": "الإعدادات", + "server_url": "رابط اللوحة", + "tls_strict": "TLS صارم", + "sign_out": "تسجيل الخروج", + "reset_client": "إعادة ضبط العميل", + "discovery_refresh": "تحديث", + "discovery_empty": "لم يتم العثور على خوادم في الشبكة المحلية" } } diff --git a/web-nodejs/lang/cs.json b/web-nodejs/lang/cs.json index 0bc2a97a..8a960432 100644 --- a/web-nodejs/lang/cs.json +++ b/web-nodejs/lang/cs.json @@ -738,7 +738,11 @@ "build_status_failed": "Neudany", "builds_summary": "{{ready}} gotowych · {{pending}} w kolejce · {{building}} sestavování · {{failed}} nieudanych", "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": { "port": "Port", @@ -1044,7 +1048,9 @@ "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.", "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": { "identity_title": "Identita značky", @@ -3828,7 +3834,8 @@ "no_groups": "Žádné skupiny", "no_tags": "Žádné štítky", "collapse_devices": "Sbalit seznam zařízení", - "expand_devices": "Rozbalit seznam zařízení" + "expand_devices": "Rozbalit seznam zařízení", + "open_settings": "Nastavení" }, "rdclient_login": { "title": "Klient vzdálené plochy", @@ -3848,5 +3855,15 @@ "network_error": "Chyba sítě", "enter_6_digits": "Zadejte všech 6 číslic", "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" } } diff --git a/web-nodejs/lang/da.json b/web-nodejs/lang/da.json index d4962cc2..01ef1523 100644 --- a/web-nodejs/lang/da.json +++ b/web-nodejs/lang/da.json @@ -744,7 +744,11 @@ "rebuild_queued": "Alle Plattform-Builds in der Warteschlange", "builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} mislykkedes", "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": { "port": "Port", @@ -1050,7 +1054,9 @@ "file_loading": "Indlæser...", "file_timeout": "Fjernenheden reagerede ikke. Filoverførsel understøttes muligvis ikke eller er deaktiveret på fjernmaskinen.", "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": { "identity_title": "Brand Identitet", @@ -3834,7 +3840,8 @@ "no_groups": "Ingen grupper", "no_tags": "Ingen tags", "collapse_devices": "Fold enhedsliste sammen", - "expand_devices": "Udvid enhedsliste" + "expand_devices": "Udvid enhedsliste", + "open_settings": "Indstillinger" }, "rdclient_login": { "title": "Remote Desktop-klient", @@ -3854,5 +3861,15 @@ "network_error": "Netværksfejl", "enter_6_digits": "Indtast alle 6 cifre", "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" } } diff --git a/web-nodejs/lang/de.json b/web-nodejs/lang/de.json index ce81c706..d5ec90e2 100644 --- a/web-nodejs/lang/de.json +++ b/web-nodejs/lang/de.json @@ -738,7 +738,11 @@ "text_color": "Textfarbe", "text_muted_color": "Gedämpfte Textfarbe", "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": { "port": "Port", @@ -1044,7 +1048,9 @@ "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.", "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": { "identity_title": "Markenidentität", @@ -3828,7 +3834,8 @@ "no_groups": "Keine Gruppen", "no_tags": "Keine Tags", "collapse_devices": "Geräteliste einklappen", - "expand_devices": "Geräteliste ausklappen" + "expand_devices": "Geräteliste ausklappen", + "open_settings": "Einstellungen" }, "rdclient_login": { "title": "Remote-Desktop-Client", @@ -3848,5 +3855,15 @@ "network_error": "Netzwerkfehler", "enter_6_digits": "Alle 6 Ziffern eingeben", "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" } } diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json index 1800d700..943bba4d 100644 --- a/web-nodejs/lang/en.json +++ b/web-nodejs/lang/en.json @@ -738,7 +738,11 @@ "usage_title": "Connection Details", "generated": "Configuration generated", "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": { "title": "Download BetterDesk Agent", @@ -1054,7 +1058,9 @@ "2fa_required": "2FA Verification Required", "2fa_hint": "Enter the 6-digit code from your authenticator app", "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": { "title": "Remote Desktop Client", @@ -1091,7 +1097,8 @@ "no_groups": "No groups", "no_tags": "No tags", "collapse_devices": "Collapse device list", - "expand_devices": "Expand device list" + "expand_devices": "Expand device list", + "open_settings": "Settings" }, "branding": { "identity_title": "Brand Identity", @@ -3848,5 +3855,15 @@ "network_error": "Network error", "enter_6_digits": "Enter all 6 digits", "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" } } diff --git a/web-nodejs/lang/es.json b/web-nodejs/lang/es.json index eba63a65..22cbe8a6 100644 --- a/web-nodejs/lang/es.json +++ b/web-nodejs/lang/es.json @@ -738,7 +738,11 @@ "text_color": "Color del texto", "text_muted_color": "Color de texto atenuado", "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": { "port": "Puerto", @@ -1044,7 +1048,9 @@ "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.", "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": { "identity_title": "Identidad de marca", @@ -3828,7 +3834,8 @@ "no_groups": "Sin grupos", "no_tags": "Sin etiquetas", "collapse_devices": "Contraer lista de dispositivos", - "expand_devices": "Expandir lista de dispositivos" + "expand_devices": "Expandir lista de dispositivos", + "open_settings": "Ajustes" }, "rdclient_login": { "title": "Cliente de escritorio remoto", @@ -3848,5 +3855,15 @@ "network_error": "Error de red", "enter_6_digits": "Introduzca los 6 dígitos", "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" } } diff --git a/web-nodejs/lang/fi.json b/web-nodejs/lang/fi.json index 5e5c0cae..3113d153 100644 --- a/web-nodejs/lang/fi.json +++ b/web-nodejs/lang/fi.json @@ -744,7 +744,11 @@ "rebuild_queued": "Alle Plattform-Builds in der Warteschlange", "builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} epäonnistui", "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": { "port": "Port", @@ -1050,7 +1054,9 @@ "file_loading": "Ladataan...", "file_timeout": "Etälaite ei vastannut. Tiedostonsiirtoa ei ehkä tueta tai se on poistettu käytöstä etäkoneessa.", "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": { "identity_title": "Brändin identiteetti", @@ -3834,7 +3840,8 @@ "no_groups": "Ei ryhmiä", "no_tags": "Ei tageja", "collapse_devices": "Tiivistä laiteluettelo", - "expand_devices": "Laajenna laiteluettelo" + "expand_devices": "Laajenna laiteluettelo", + "open_settings": "Asetukset" }, "rdclient_login": { "title": "Etätyöpöytäasiakas", @@ -3854,5 +3861,15 @@ "network_error": "Verkkovirhe", "enter_6_digits": "Syötä kaikki 6 numeroa", "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" } } diff --git a/web-nodejs/lang/fr.json b/web-nodejs/lang/fr.json index 8fbbba89..aa3a7ece 100644 --- a/web-nodejs/lang/fr.json +++ b/web-nodejs/lang/fr.json @@ -742,7 +742,11 @@ "usage_title": "Détails de connexion", "generated": "Configuration générée", "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": { "title": "BetterDesk Agent herunterladen", @@ -1058,7 +1062,9 @@ "2fa_required": "2FA Verification Required", "2fa_hint": "Enter the 6-digit code from your authenticator app", "2fa_invalid": "Enter a valid 6-digit code", - "verify": "Verify" + "verify": "Verify", + "remember_peer_password": "Mémoriser le mot de passe de l’appareil sur cet ordinateur", + "save_peer_password": "Enregistrer le mot de passe" }, "branding": { "identity_title": "Identité de marque", @@ -3832,7 +3838,8 @@ "no_groups": "Aucun groupe", "no_tags": "Aucun tag", "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": { "title": "Client Bureau à distance", @@ -3852,5 +3859,15 @@ "network_error": "Erreur réseau", "enter_6_digits": "Entrez les 6 chiffres", "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" } } diff --git a/web-nodejs/lang/hi.json b/web-nodejs/lang/hi.json index 6799c5f8..bc3d5a72 100644 --- a/web-nodejs/lang/hi.json +++ b/web-nodejs/lang/hi.json @@ -744,7 +744,11 @@ "build_status_failed": "विफल", "builds_summary": "{{ready}} तैयार · {{pending}} कतार में · {{building}} बिल्ड हो रहा है · {{failed}} विफल", "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": { "port": "Port", @@ -1050,7 +1054,9 @@ "file_loading": "लोड हो रहा है...", "file_timeout": "रिमोट डिवाइस ने प्रतिक्रिया नहीं दी. फ़ाइल स्थानांतरण समर्थित नहीं हो सकता है या रिमोट मशीन पर अक्षम है।", "codec": "वीडियो कोडेक", - "codec_auto": "ऑटो (अनुशंसित)" + "codec_auto": "ऑटो (अनुशंसित)", + "remember_peer_password": "इस डिवाइस पर डिवाइस पासवर्ड याद रखें", + "save_peer_password": "पासवर्ड सहेजें" }, "branding": { "identity_title": "ब्रांड पहचान", @@ -3834,7 +3840,8 @@ "no_groups": "कोई समूह नहीं", "no_tags": "कोई टैग नहीं", "collapse_devices": "डिवाइस सूची संक्षिप्त करें", - "expand_devices": "डिवाइस सूची विस्तृत करें" + "expand_devices": "डिवाइस सूची विस्तृत करें", + "open_settings": "सेटिंग्स" }, "rdclient_login": { "title": "रिमोट डेस्कटॉप क्लाइंट", @@ -3854,5 +3861,15 @@ "network_error": "नेटवर्क त्रुटि", "enter_6_digits": "सभी 6 अंक दर्ज करें", "invalid_code": "अमान्य कोड" + }, + "rdclient_settings": { + "title": "RdClient सेटिंग्स", + "open": "सेटिंग्स", + "server_url": "पैनल URL", + "tls_strict": "सख्त TLS", + "sign_out": "साइन आउट", + "reset_client": "क्लाइंट रीसेट करें", + "discovery_refresh": "रीफ़्रेश", + "discovery_empty": "स्थानीय नेटवर्क पर कोई सर्वर नहीं मिला" } } diff --git a/web-nodejs/lang/hu.json b/web-nodejs/lang/hu.json index f56ec01e..14026465 100644 --- a/web-nodejs/lang/hu.json +++ b/web-nodejs/lang/hu.json @@ -744,7 +744,11 @@ "rebuild_queued": "Alle Plattform-Builds in der Warteschlange", "builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} sikertelen", "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": { "port": "Port", @@ -1050,7 +1054,9 @@ "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.", "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": { "identity_title": "Márkaidentitás", @@ -3834,7 +3840,8 @@ "no_groups": "Nincs csoport", "no_tags": "Nincs címke", "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": { "title": "Távoli asztal kliens", @@ -3854,5 +3861,15 @@ "network_error": "Hálózati hiba", "enter_6_digits": "Adja meg mind a 6 számjegyet", "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" } } diff --git a/web-nodejs/lang/id.json b/web-nodejs/lang/id.json index 81a9c5ce..11fc8a8d 100644 --- a/web-nodejs/lang/id.json +++ b/web-nodejs/lang/id.json @@ -744,7 +744,11 @@ "build_status_failed": "Gagal", "builds_summary": "{{ready}} siap · {{pending}} antrian · {{building}} membangun · {{failed}} gagal", "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": { "port": "Port", @@ -1050,7 +1054,9 @@ "file_loading": "Memuat...", "file_timeout": "Perangkat jarak jauh tidak merespons. Transfer file mungkin tidak didukung atau dinonaktifkan pada mesin jarak jauh.", "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": { "identity_title": "Identitas Merek", @@ -3834,7 +3840,8 @@ "no_groups": "Tidak ada grup", "no_tags": "Tidak ada tag", "collapse_devices": "Ciutkan daftar perangkat", - "expand_devices": "Perluas daftar perangkat" + "expand_devices": "Perluas daftar perangkat", + "open_settings": "Pengaturan" }, "rdclient_login": { "title": "Klien Desktop Jarak Jauh", @@ -3854,5 +3861,15 @@ "network_error": "Kesalahan jaringan", "enter_6_digits": "Masukkan semua 6 digit", "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" } } diff --git a/web-nodejs/lang/it.json b/web-nodejs/lang/it.json index 62d82f16..86216bd3 100644 --- a/web-nodejs/lang/it.json +++ b/web-nodejs/lang/it.json @@ -738,7 +738,11 @@ "build_status_failed": "Èchouè", "builds_summary": "{{ready}} prèt · {{pending}} en file d'attesa · {{building}} en compilation · {{failed}} èchouè", "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": { "port": "Porta", @@ -1044,7 +1048,9 @@ "file_loading": "Caricamento...", "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_auto": "Automatico (consigliato)" + "codec_auto": "Automatico (consigliato)", + "remember_peer_password": "Ricorda password dispositivo su questo computer", + "save_peer_password": "Salva password" }, "branding": { "identity_title": "Identità del marchio", @@ -3828,7 +3834,8 @@ "no_groups": "Nessun gruppo", "no_tags": "Nessun tag", "collapse_devices": "Comprimi elenco dispositivi", - "expand_devices": "Espandi elenco dispositivi" + "expand_devices": "Espandi elenco dispositivi", + "open_settings": "Impostazioni" }, "rdclient_login": { "title": "Client desktop remoto", @@ -3848,5 +3855,15 @@ "network_error": "Errore di rete", "enter_6_digits": "Inserisci tutte le 6 cifre", "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" } } diff --git a/web-nodejs/lang/ja.json b/web-nodejs/lang/ja.json index e641d50f..81df1a7d 100644 --- a/web-nodejs/lang/ja.json +++ b/web-nodejs/lang/ja.json @@ -738,7 +738,11 @@ "build_status_failed": "失敗", "builds_summary": "{{ready}} 準備完了 · {{pending}} キュー中 · {{building}} ビルド中 · {{failed}} 失敗", "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": { "port": "Port", @@ -1044,7 +1048,9 @@ "file_loading": "読み込み中...", "file_timeout": "リモートデバイスが応答しませんでした。ファイル転送がサポートされていないか、リモート マシンで無効になっている可能性があります。", "codec": "ビデオコーデック", - "codec_auto": "自動 (推奨)" + "codec_auto": "自動 (推奨)", + "remember_peer_password": "このデバイスでデバイスパスワードを記憶", + "save_peer_password": "パスワードを保存" }, "branding": { "identity_title": "ブランドアイデンティティ", @@ -3828,7 +3834,8 @@ "no_groups": "グループなし", "no_tags": "タグなし", "collapse_devices": "デバイス一覧を折りたたむ", - "expand_devices": "デバイス一覧を展開" + "expand_devices": "デバイス一覧を展開", + "open_settings": "設定" }, "rdclient_login": { "title": "リモートデスクトップクライアント", @@ -3848,5 +3855,15 @@ "network_error": "ネットワークエラー", "enter_6_digits": "6桁すべてを入力してください", "invalid_code": "無効なコード" + }, + "rdclient_settings": { + "title": "RdClient 設定", + "open": "設定", + "server_url": "パネル URL", + "tls_strict": "厳格 TLS", + "sign_out": "サインアウト", + "reset_client": "クライアントをリセット", + "discovery_refresh": "更新", + "discovery_empty": "ローカルネットワークにサーバーが見つかりません" } } diff --git a/web-nodejs/lang/ko.json b/web-nodejs/lang/ko.json index 8cadf181..78182fac 100644 --- a/web-nodejs/lang/ko.json +++ b/web-nodejs/lang/ko.json @@ -738,7 +738,11 @@ "build_status_failed": "실패", "builds_summary": "{{ready}} 준비 완료 · {{pending}} 대기 중 · {{building}} 빌드 중 · {{failed}} 실패", "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": { "port": "Port", @@ -1044,7 +1048,9 @@ "file_loading": "로드 중...", "file_timeout": "원격 장치가 응답하지 않았습니다. 원격 시스템에서 파일 전송이 지원되지 않거나 비활성화되어 있을 수 있습니다.", "codec": "비디오 코덱", - "codec_auto": "자동(권장)" + "codec_auto": "자동(권장)", + "remember_peer_password": "이 기기에서 장치 비밀번호 기억", + "save_peer_password": "비밀번호 저장" }, "branding": { "identity_title": "브랜드 아이덴티티", @@ -3828,7 +3834,8 @@ "no_groups": "그룹 없음", "no_tags": "태그 없음", "collapse_devices": "장치 목록 접기", - "expand_devices": "장치 목록 펼치기" + "expand_devices": "장치 목록 펼치기", + "open_settings": "설정" }, "rdclient_login": { "title": "원격 데스크톱 클라이언트", @@ -3848,5 +3855,15 @@ "network_error": "네트워크 오류", "enter_6_digits": "6자리 모두 입력하세요", "invalid_code": "잘못된 코드" + }, + "rdclient_settings": { + "title": "RdClient 설정", + "open": "설정", + "server_url": "패널 URL", + "tls_strict": "엄격 TLS", + "sign_out": "로그아웃", + "reset_client": "클라이언트 재설정", + "discovery_refresh": "새로고침", + "discovery_empty": "로컬 네트워크에서 서버를 찾을 수 없습니다" } } diff --git a/web-nodejs/lang/nb.json b/web-nodejs/lang/nb.json index b8e3db92..07a5c70f 100644 --- a/web-nodejs/lang/nb.json +++ b/web-nodejs/lang/nb.json @@ -744,7 +744,11 @@ "rebuild_queued": "Alle Plattform-Builds in der Warteschlange", "builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} mislyktes", "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": { "port": "Port", @@ -1050,7 +1054,9 @@ "file_loading": "Laster inn...", "file_timeout": "Ekstern enhet svarte ikke. Filoverføring støttes kanskje ikke eller er deaktivert på den eksterne maskinen.", "codec": "Videokodek", - "codec_auto": "Auto (anbefalt)" + "codec_auto": "Auto (anbefalt)", + "remember_peer_password": "Husk enhetspassord på denne datamaskinen", + "save_peer_password": "Lagre passord" }, "branding": { "identity_title": "Merkeidentitet", @@ -3834,7 +3840,8 @@ "no_groups": "Ingen grupper", "no_tags": "Ingen tagger", "collapse_devices": "Skjul enhetsliste", - "expand_devices": "Vis enhetsliste" + "expand_devices": "Vis enhetsliste", + "open_settings": "Innstillinger" }, "rdclient_login": { "title": "Remote Desktop-klient", @@ -3854,5 +3861,15 @@ "network_error": "Nettverksfeil", "enter_6_digits": "Skriv inn alle 6 sifre", "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" } } diff --git a/web-nodejs/lang/nl.json b/web-nodejs/lang/nl.json index bbdd2550..b45c2a42 100644 --- a/web-nodejs/lang/nl.json +++ b/web-nodejs/lang/nl.json @@ -738,7 +738,11 @@ "build_status_failed": "Mislukt", "builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} mislukt", "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": { "port": "Poort", @@ -1044,7 +1048,9 @@ "file_loading": "Laden...", "file_timeout": "Het externe apparaat reageerde niet. Bestandsoverdracht wordt mogelijk niet ondersteund of is uitgeschakeld op de externe computer.", "codec": "Videocodec", - "codec_auto": "Automatisch (aanbevolen)" + "codec_auto": "Automatisch (aanbevolen)", + "remember_peer_password": "Apparaatwachtwoord onthouden op dit apparaat", + "save_peer_password": "Wachtwoord opslaan" }, "branding": { "identity_title": "Merkidentiteit", @@ -3828,7 +3834,8 @@ "no_groups": "Geen groepen", "no_tags": "Geen tags", "collapse_devices": "Apparaatlijst inklappen", - "expand_devices": "Apparaatlijst uitklappen" + "expand_devices": "Apparaatlijst uitklappen", + "open_settings": "Instellingen" }, "rdclient_login": { "title": "Remote Desktop-client", @@ -3848,5 +3855,15 @@ "network_error": "Netwerkfout", "enter_6_digits": "Voer alle 6 cijfers in", "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" } } diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json index 317940c9..2303e568 100644 --- a/web-nodejs/lang/pl.json +++ b/web-nodejs/lang/pl.json @@ -738,7 +738,11 @@ "generated": "Konfiguracja wygenerowana", "server_required": "Adres serwera jest wymagany", "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": { "title": "Pobierz Agenta BetterDesk", @@ -1054,7 +1058,9 @@ "2fa_required": "Wymagana weryfikacja 2FA", "2fa_hint": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej", "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": { "identity_title": "Identyfikacja marki", @@ -3828,7 +3834,8 @@ "no_groups": "Brak grup", "no_tags": "Brak tagów", "collapse_devices": "Zwiń listę urządzeń", - "expand_devices": "Rozwiń listę urządzeń" + "expand_devices": "Rozwiń listę urządzeń", + "open_settings": "Ustawienia" }, "rdclient_login": { "title": "Klient pulpitu zdalnego", @@ -3848,5 +3855,15 @@ "network_error": "Błąd sieci", "enter_6_digits": "Wprowadź wszystkie 6 cyfr", "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" } } diff --git a/web-nodejs/lang/pt.json b/web-nodejs/lang/pt.json index 0ee6e983..ae68eae4 100644 --- a/web-nodejs/lang/pt.json +++ b/web-nodejs/lang/pt.json @@ -738,7 +738,11 @@ "build_status_failed": "Falhou", "builds_summary": "{{ready}} pronto · {{pending}} en fila · {{building}} compilando · {{failed}} falhou", "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": { "port": "Porta", @@ -1044,7 +1048,9 @@ "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.", "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": { "identity_title": "Identidade da marca", @@ -3828,7 +3834,8 @@ "no_groups": "Sem grupos", "no_tags": "Sem etiquetas", "collapse_devices": "Recolher lista de dispositivos", - "expand_devices": "Expandir lista de dispositivos" + "expand_devices": "Expandir lista de dispositivos", + "open_settings": "Definições" }, "rdclient_login": { "title": "Cliente de ambiente de trabalho remoto", @@ -3848,5 +3855,15 @@ "network_error": "Erro de rede", "enter_6_digits": "Introduza os 6 dígitos", "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" } } diff --git a/web-nodejs/lang/ro.json b/web-nodejs/lang/ro.json index e6fd66f5..3a8dbe39 100644 --- a/web-nodejs/lang/ro.json +++ b/web-nodejs/lang/ro.json @@ -744,7 +744,11 @@ "rebuild_queued": "Alle Plattform-Builds in der Warteschlange", "builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} eșuat", "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": { "port": "Port", @@ -1050,7 +1054,9 @@ "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ță.", "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": { "identity_title": "Identitatea de marcă", @@ -3834,7 +3840,8 @@ "no_groups": "Fără grupuri", "no_tags": "Fără etichete", "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": { "title": "Client desktop la distanță", @@ -3854,5 +3861,15 @@ "network_error": "Eroare de rețea", "enter_6_digits": "Introduceți toate cele 6 cifre", "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ă" } } diff --git a/web-nodejs/lang/sv.json b/web-nodejs/lang/sv.json index 53385bb9..88ec1487 100644 --- a/web-nodejs/lang/sv.json +++ b/web-nodejs/lang/sv.json @@ -744,7 +744,11 @@ "rebuild_queued": "Alle Plattform-Builds in der Warteschlange", "builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} misslyckades", "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": { "port": "Port", @@ -1050,7 +1054,9 @@ "file_loading": "Laddar...", "file_timeout": "Fjärrenheten svarade inte. Filöverföring kanske inte stöds eller är inaktiverad på fjärrmaskinen.", "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": { "identity_title": "Varumärkesidentitet", @@ -3834,7 +3840,8 @@ "no_groups": "Inga grupper", "no_tags": "Inga taggar", "collapse_devices": "Fäll ihop enhetslista", - "expand_devices": "Expandera enhetslista" + "expand_devices": "Expandera enhetslista", + "open_settings": "Inställningar" }, "rdclient_login": { "title": "Remote Desktop-klient", @@ -3854,5 +3861,15 @@ "network_error": "Nätverksfel", "enter_6_digits": "Ange alla 6 siffror", "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" } } diff --git a/web-nodejs/lang/th.json b/web-nodejs/lang/th.json index 3a18049e..e81f2107 100644 --- a/web-nodejs/lang/th.json +++ b/web-nodejs/lang/th.json @@ -744,7 +744,11 @@ "build_status_failed": "ล้มเหลว", "builds_summary": "{{ready}} พร้อม · {{pending}} รอคิว · {{building}} กำลัง Build · {{failed}} ล้มเหลว", "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": { "port": "Port", @@ -1050,7 +1054,9 @@ "file_loading": "กำลังโหลด...", "file_timeout": "อุปกรณ์ระยะไกลไม่ตอบสนอง การถ่ายโอนไฟล์อาจไม่รองรับหรือถูกปิดใช้งานบนเครื่องระยะไกล", "codec": "ตัวแปลงสัญญาณวิดีโอ", - "codec_auto": "อัตโนมัติ (แนะนำ)" + "codec_auto": "อัตโนมัติ (แนะนำ)", + "remember_peer_password": "จดจำรหัสผ่านอุปกรณ์บนคอมพิวเตอร์นี้", + "save_peer_password": "บันทึกรหัสผ่าน" }, "branding": { "identity_title": "เอกลักษณ์ของแบรนด์", @@ -3834,7 +3840,8 @@ "no_groups": "ไม่มีกลุ่ม", "no_tags": "ไม่มีแท็ก", "collapse_devices": "ยุบรายการอุปกรณ์", - "expand_devices": "ขยายรายการอุปกรณ์" + "expand_devices": "ขยายรายการอุปกรณ์", + "open_settings": "การตั้งค่า" }, "rdclient_login": { "title": "ไคลเอนต์เดสก์ท็อประยะไกล", @@ -3854,5 +3861,15 @@ "network_error": "ข้อผิดพลาดเครือข่าย", "enter_6_digits": "ป้อนตัวเลขครบ 6 หลัก", "invalid_code": "รหัสไม่ถูกต้อง" + }, + "rdclient_settings": { + "title": "การตั้งค่า RdClient", + "open": "การตั้งค่า", + "server_url": "URL แผงควบคุม", + "tls_strict": "TLS เข้มงวด", + "sign_out": "ออกจากระบบ", + "reset_client": "รีเซ็ตไคลเอนต์", + "discovery_refresh": "รีเฟรช", + "discovery_empty": "ไม่พบเซิร์ฟเวอร์ในเครือข่ายท้องถิ่น" } } diff --git a/web-nodejs/lang/tr.json b/web-nodejs/lang/tr.json index 9443ed27..e35cb27f 100644 --- a/web-nodejs/lang/tr.json +++ b/web-nodejs/lang/tr.json @@ -744,7 +744,11 @@ "build_status_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ı", - "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 URL’li RdClient yükleyicileri oluştur", + "rdclient_server_url_hint": "BetterDesk konsolunun genel URL’si (/remote)." }, "server": { "port": "Port", @@ -1050,7 +1054,9 @@ "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.", "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": { "identity_title": "Marka Kimliği", @@ -3834,7 +3840,8 @@ "no_groups": "Grup yok", "no_tags": "Etiket yok", "collapse_devices": "Cihaz listesini daralt", - "expand_devices": "Cihaz listesini genişlet" + "expand_devices": "Cihaz listesini genişlet", + "open_settings": "Ayarlar" }, "rdclient_login": { "title": "Uzak Masaüstü İstemcisi", @@ -3854,5 +3861,15 @@ "network_error": "Ağ hatası", "enter_6_digits": "6 hanenin tamamını girin", "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ı" } } diff --git a/web-nodejs/lang/uk.json b/web-nodejs/lang/uk.json index e5c0f748..bcd4956e 100644 --- a/web-nodejs/lang/uk.json +++ b/web-nodejs/lang/uk.json @@ -744,7 +744,11 @@ "rebuild_queued": "Alle Plattform-Builds in der Warteschlange", "builds_summary": "{{ready}} bereit · {{pending}} in Warteschlange · {{building}} wird erstellt · {{failed}} не вдалося", "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": { "port": "Port", @@ -1050,7 +1054,9 @@ "file_loading": "Завантаження...", "file_timeout": "Віддалений пристрій не відповідає. Передача файлів може не підтримуватися або вимкнена на віддаленій машині.", "codec": "Відеокодек", - "codec_auto": "Авто (рекомендовано)" + "codec_auto": "Авто (рекомендовано)", + "remember_peer_password": "Запам’ятати пароль пристрою на цьому комп’ютері", + "save_peer_password": "Зберегти пароль" }, "branding": { "identity_title": "Ідентичність бренду", @@ -3834,7 +3840,8 @@ "no_groups": "Немає груп", "no_tags": "Немає тегів", "collapse_devices": "Згорнути список пристроїв", - "expand_devices": "Розгорнути список пристроїв" + "expand_devices": "Розгорнути список пристроїв", + "open_settings": "Налаштування" }, "rdclient_login": { "title": "Клієнт віддаленого робочого столу", @@ -3854,5 +3861,15 @@ "network_error": "Помилка мережі", "enter_6_digits": "Введіть усі 6 цифр", "invalid_code": "Невірний код" + }, + "rdclient_settings": { + "title": "Налаштування RdClient", + "open": "Налаштування", + "server_url": "URL панелі", + "tls_strict": "Суворий TLS", + "sign_out": "Вийти", + "reset_client": "Скинути клієнт", + "discovery_refresh": "Оновити", + "discovery_empty": "Серверів у локальній мережі не знайдено" } } diff --git a/web-nodejs/lang/vi.json b/web-nodejs/lang/vi.json index 7da2e406..145490f2 100644 --- a/web-nodejs/lang/vi.json +++ b/web-nodejs/lang/vi.json @@ -744,7 +744,11 @@ "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", "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": { "port": "Port", @@ -1050,7 +1054,9 @@ "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.", "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": { "identity_title": "Nhận diện thương hiệu", @@ -3834,7 +3840,8 @@ "no_groups": "Không có nhóm", "no_tags": "Không có thẻ", "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": { "title": "Máy khách Desktop từ xa", @@ -3854,5 +3861,15 @@ "network_error": "Lỗi mạng", "enter_6_digits": "Nhập đủ 6 chữ số", "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ộ" } } diff --git a/web-nodejs/lang/zh-TW.json b/web-nodejs/lang/zh-TW.json index ce4c3de1..144d3fa5 100644 --- a/web-nodejs/lang/zh-TW.json +++ b/web-nodejs/lang/zh-TW.json @@ -742,7 +742,11 @@ "usage_title": "連接詳情", "generated": "配置已生成", "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": { "title": "下載 BetterDesk 代理", @@ -1058,7 +1062,9 @@ "2fa_required": "需要雙因素驗證", "2fa_hint": "輸入驗證器應用中的6位數代碼", "2fa_invalid": "請輸入有效的6位數代碼", - "verify": "驗證" + "verify": "驗證", + "remember_peer_password": "在此裝置上記住裝置密碼", + "save_peer_password": "儲存密碼" }, "branding": { "identity_title": "品牌標識", @@ -3832,7 +3838,8 @@ "no_groups": "無群組", "no_tags": "無標籤", "collapse_devices": "摺疊裝置清單", - "expand_devices": "展開裝置清單" + "expand_devices": "展開裝置清單", + "open_settings": "設定" }, "rdclient_login": { "title": "遠端桌面用戶端", @@ -3852,5 +3859,15 @@ "network_error": "網路錯誤", "enter_6_digits": "請輸入全部6位數字", "invalid_code": "代碼無效" + }, + "rdclient_settings": { + "title": "RdClient 設定", + "open": "設定", + "server_url": "面板 URL", + "tls_strict": "嚴格 TLS", + "sign_out": "登出", + "reset_client": "重設用戶端", + "discovery_refresh": "重新整理", + "discovery_empty": "區域網路中找不到伺服器" } } diff --git a/web-nodejs/lang/zh.json b/web-nodejs/lang/zh.json index bba34ac8..7fe352a5 100644 --- a/web-nodejs/lang/zh.json +++ b/web-nodejs/lang/zh.json @@ -738,7 +738,11 @@ "build_status_failed": "失败", "builds_summary": "{{ready}} 就绪 · {{pending}} 排队中 · {{building}} 构建中 · {{failed}} 失败", "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": { "port": "端口", @@ -1044,7 +1048,9 @@ "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.", "codec": "视频编解码器", - "codec_auto": "自动(推荐)" + "codec_auto": "自动(推荐)", + "remember_peer_password": "在此设备上记住设备密码", + "save_peer_password": "保存密码" }, "branding": { "identity_title": "品牌标识", @@ -3828,7 +3834,8 @@ "no_groups": "无分组", "no_tags": "无标签", "collapse_devices": "折叠设备列表", - "expand_devices": "展开设备列表" + "expand_devices": "展开设备列表", + "open_settings": "设置" }, "rdclient_login": { "title": "远程桌面客户端", @@ -3848,5 +3855,15 @@ "network_error": "网络错误", "enter_6_digits": "请输入全部6位数字", "invalid_code": "代码无效" + }, + "rdclient_settings": { + "title": "RdClient 设置", + "open": "设置", + "server_url": "面板 URL", + "tls_strict": "严格 TLS", + "sign_out": "退出登录", + "reset_client": "重置客户端", + "discovery_refresh": "刷新", + "discovery_empty": "在本地网络中未找到服务器" } } diff --git a/web-nodejs/public/css/remote-dashboard.css b/web-nodejs/public/css/remote-dashboard.css index 2f7fa945..1251c3f9 100644 --- a/web-nodejs/public/css/remote-dashboard.css +++ b/web-nodejs/public/css/remote-dashboard.css @@ -8,16 +8,16 @@ html { } html.rd-desk-desktop { - height: 100%; - height: 100vh; - max-height: 100vh; + height: var(--rd-desk-vh, 100vh); + max-height: var(--rd-desk-vh, 100vh); overflow: hidden; + overscroll-behavior: none; } .rd-desk-body { margin: 0; height: 100%; - min-height: 100%; + min-height: 0; overflow: hidden; background: var(--bg-primary, #0d1117); color: var(--text-primary, #e6edf3); @@ -27,9 +27,14 @@ html.rd-desk-desktop { } .rd-desk-desktop.rd-desk-body { - height: 100vh; - max-height: 100vh; + position: fixed; + inset: 0; + width: 100%; + height: var(--rd-desk-vh, 100vh) !important; + max-height: var(--rd-desk-vh, 100vh); min-height: 0; + overflow: hidden !important; + overscroll-behavior: none; } .rd-desk-app { @@ -43,8 +48,10 @@ html.rd-desk-desktop { } .rd-desk-desktop .rd-desk-app { - height: 100vh; - max-height: 100vh; + position: absolute; + inset: 0; + height: 100% !important; + max-height: 100% !important; } /* ── Header ── */ @@ -193,6 +200,35 @@ html.rd-desk-desktop { 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 ── */ .rd-desk-workspace { display: flex; @@ -204,14 +240,24 @@ html.rd-desk-desktop { /* ── Sidebar ── */ .rd-desk-sidebar { width: 240px; - flex-shrink: 0; + flex: 0 0 240px; display: flex; flex-direction: column; min-height: 0; overflow: hidden; border-right: 1px solid var(--border-primary, #30363d); 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 { @@ -267,20 +313,13 @@ html.rd-desk-desktop { .rd-desk-nav-body { min-height: 0; - max-height: min(220px, 28vh); - overflow-y: auto; - overflow-x: hidden; - overscroll-behavior: contain; + overflow: visible; } .rd-desk-nav-section.is-collapsed .rd-desk-nav-body { display: none; } -.rd-desk-desktop .rd-desk-nav-body { - max-height: min(180px, 24vh); -} - .rd-desk-nav-list { list-style: none; margin: 0; @@ -359,6 +398,7 @@ html.rd-desk-desktop { /* ── Content ── */ .rd-desk-content { flex: 1; + min-height: 0; display: flex; flex-direction: column; min-width: 0; @@ -872,16 +912,13 @@ html.rd-desk-desktop { } .rd-desk-sidebar { + flex: 0 0 auto; width: 100%; max-height: min(240px, 35vh); border-right: none; border-bottom: 1px solid var(--border-primary, #30363d); } - .rd-desk-nav-body { - max-height: min(140px, 18vh); - } - .rd-desk-quick-label { display: none; } diff --git a/web-nodejs/public/js/generator.js b/web-nodejs/public/js/generator.js index 01220ed7..0328fa11 100644 --- a/web-nodejs/public/js/generator.js +++ b/web-nodejs/public/js/generator.js @@ -46,6 +46,7 @@ slugManual: false, previewTimer: null, buildsPollTimer: null, + productType: 'agent', }; const $ = (id) => document.getElementById(id); @@ -421,14 +422,16 @@ els['gen-save-btn'].disabled = true; } - function setEditorForNew() { + function setEditorForNew(productType) { state.currentId = 'new'; state.currentBundle = null; state.currentBuilds = []; state.dirty = false; state.slugManual = false; + state.productType = productType || 'agent'; stopBuildsPoll(); - els['gen-editor-title'].innerHTML = `add_circle ${escapeText(t('generator.new_bundle', 'New bundle'))}`; + const titleKey = state.productType === 'rdclient' ? 'generator.rdclient_new_bundle' : 'generator.new_bundle'; + els['gen-editor-title'].innerHTML = `add_circle ${escapeText(t(titleKey, 'New bundle'))}`; els['gen-name'].value = ''; if (els['gen-slug']) els['gen-slug'].value = ''; writeBranding(DEFAULT_BRANDING); @@ -453,6 +456,7 @@ function setEditorForBundle(bundle) { state.currentId = bundle.bundle_id; state.currentBundle = bundle; + state.productType = bundle.product_type || 'agent'; state.dirty = false; state.slugManual = true; stopBuildsPoll(); @@ -514,6 +518,7 @@ name: els['gen-name'].value.trim(), slug: readSlugInput(), branding: readBranding(), + product_type: state.productType || 'agent', }; if (!payload.name) { showErrors([t('generator.errors.name_required', 'Bundle name is required')]); @@ -653,7 +658,9 @@ } 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-rebuild-btn'].addEventListener('click', rebuildAllBuilds); els['gen-revoke-btn'].addEventListener('click', toggleRevoke); diff --git a/web-nodejs/public/js/rdclient/cdap-adapter.js b/web-nodejs/public/js/rdclient/cdap-adapter.js index e9251042..595b5034 100644 --- a/web-nodejs/public/js/rdclient/cdap-adapter.js +++ b/web-nodejs/public/js/rdclient/cdap-adapter.js @@ -88,23 +88,23 @@ && typeof window.VideoDecoder !== 'undefined'; } + /** @type {Promise|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 - * 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). + * Sync fallback before async probe completes. * @returns {string[]} */ - function decodableCodecs() { + function decodableCodecsSync() { const list = []; 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()) { list.push('h264'); } @@ -112,6 +112,33 @@ return list; } + /** + * Probe WebCodecs support and return wire codecs safe to advertise. + * @returns {Promise} + */ + 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 * `remote.js` (resize + scale mode). Frames are painted directly by @@ -217,6 +244,8 @@ // from `desktop_meta` and defaults to the legacy MJPEG path. this._activeFormat = 'jpeg'; this._codecString = null; + this._codecFallbackDone = false; + this._videoRenderedFrames = 0; this._video = null; // RDVideo instance (lazy) this._gotKeyframe = false; this._keyframeRequestedAt = 0; @@ -268,6 +297,7 @@ async connect() { this._setState('connecting'); + this._codecFallbackDone = false; this._emit('log', 'Opening CDAP desktop session…'); console.log('[CDAP] connect()', this.deviceId); @@ -495,6 +525,17 @@ // ── Internal: WS lifecycle ─────────────────────────────────────── _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 rect = this.canvas.getBoundingClientRect(); const screenW = (window.screen && window.screen.width) || 1920; @@ -515,7 +556,7 @@ // sends a GPU-encoded video stream. Over plain HTTP this still // includes H.264 (decoded via JMuxer/MSE), avoiding the slow // MJPEG fallback. See decodableCodecs(). - codecs: decodableCodecs(), + codecs: codecs, video_codec: 'auto', }); @@ -742,21 +783,45 @@ return; } this._closeVideoDecoder(); + this._videoRenderedFrames = 0; const v = new RDVideo(); this._video = v; - v.onFrame = (frame) => this._drawVideoFrame(frame); + v.onFrame = (frame) => { + this._videoRenderedFrames++; + this._drawVideoFrame(frame); + }; 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._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); - 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 * access unit with a single flag byte (bit0 = keyframe); the Node proxy @@ -788,7 +853,12 @@ 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 + }); } /** diff --git a/web-nodejs/public/js/rdclient/client.js b/web-nodejs/public/js/rdclient/client.js index 21c07521..5d8c0586 100644 --- a/web-nodejs/public/js/rdclient/client.js +++ b/web-nodejs/public/js/rdclient/client.js @@ -62,6 +62,7 @@ class RDClient { this._codecAbilities = null; // probed VideoDecoder support map this._preferCodec = opts.preferCodec || 'Auto'; this._adaptivePaused = false; // true once the user picks codec/quality manually + this._codecFallbackDone = false; // one automatic downgrade per session // Relay state tracking this._relayFrameIdx = 0; // Counter for relay frames (debugging) @@ -1052,6 +1053,7 @@ class RDClient { */ _startSession() { this._setState('streaming'); + this._codecFallbackDone = false; this.conn.setConnected(); // Enable file transfer @@ -1068,6 +1070,9 @@ class RDClient { this._sendPeerMessage(this.proto.buildMisc('refreshVideo', true)); } }; + this.video.onCodecFailed = (failedCodec) => { + this._handleCodecFallback(failedCodec); + }; // Request keyframe on resize/fullscreen to fix blur this.renderer.onResizeRefresh = () => { @@ -1325,6 +1330,7 @@ class RDClient { this.audio.close(); this.fileTransfer.disable(); this.conn.close(); + this._codecFallbackDone = false; } // ---- Public Utility Methods ---- @@ -1651,6 +1657,33 @@ class RDClient { 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 */ diff --git a/web-nodejs/public/js/rdclient/video.js b/web-nodejs/public/js/rdclient/video.js index fdefefdb..55cdbb31 100644 --- a/web-nodejs/public/js/rdclient/video.js +++ b/web-nodejs/public/js/rdclient/video.js @@ -65,9 +65,12 @@ class RDVideo { this._codecConfig = null; /** @type {boolean} Whether we already retried software decoding after a hardware failure */ this._softwareRetry = false; - /** @type {number} Last decoder error timestamp (ms) for throttled recovery */ - this._lastDecodeErrorTime = 0; - } + /** @type {Function|null} Called when decoder cannot recover (codec switch needed) */ + 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) @@ -85,6 +88,56 @@ class RDVideo { 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 * @returns {boolean} @@ -102,7 +155,7 @@ class RDVideo { const map = { vp9: ['vp09.00.10.08', 'vp09.00.40.08', 'vp09.00.50.08'], 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'], h265: ['hev1.1.6.L93.B0', 'hvc1.1.6.L93.B0'] }; @@ -115,12 +168,15 @@ class RDVideo { * @param {string} [accel] hardwareAcceleration preference * @returns {Promise} supported VideoDecoderConfig */ - static async resolveCodecConfig(codecName, accel) { - const candidates = RDVideo.codecCandidates(codecName); + static async resolveCodecConfig(codecName, accel, explicitCodec) { + const candidates = explicitCodec + ? [explicitCodec].concat(RDVideo.codecCandidates(codecName).filter((c) => c !== explicitCodec)) + : RDVideo.codecCandidates(codecName); if (!candidates.length) return null; - const probes = accel - ? [{ hardwareAcceleration: accel }, {}] + const baseAccel = accel || RDVideo.defaultAcceleration(codecName); + const probes = baseAccel + ? [{ hardwareAcceleration: baseAccel }, { hardwareAcceleration: 'prefer-software' }, {}] : [{ hardwareAcceleration: 'prefer-hardware' }, { hardwareAcceleration: 'prefer-software' }, {}]; for (const codec of candidates) { @@ -158,17 +214,44 @@ class RDVideo { const names = ['vp9', 'h264', 'av1', 'vp8', 'h265']; const result = {}; 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; } + /** + * 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} + */ + 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. * Uses WebCodecs if available, otherwise falls back to JMuxer for H.264. * @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) { this.close(); } @@ -207,16 +290,18 @@ class RDVideo { throw new Error(`Unsupported codec: ${codecName}`); } - // Choose acceleration: prefer hardware, but fall back to software after - // a hardware decode failure (some Linux/Chrome setups have flaky - // hardware H.264 paths that silently stop producing frames). - const accel = this._softwareRetry ? 'prefer-software' : 'prefer-hardware'; + const accel = this._softwareRetry + ? 'prefer-software' + : RDVideo.defaultAcceleration(codecName); - const resolved = await RDVideo.resolveCodecConfig(codecName, accel); + const resolved = await RDVideo.resolveCodecConfig(codecName, accel, opts.codecString || null); if (!resolved || !resolved.codec) { throw new Error(`Codec ${codecName} not supported by browser`); } + this._softwareRetry = resolved.hardwareAcceleration === 'prefer-software' + || resolved.hardwareAcceleration === 'no-preference'; + this.decoder = new VideoDecoder({ output: (frame) => this._handleDecodedFrame(frame), error: (err) => this._handleError(err) @@ -228,8 +313,16 @@ class RDVideo { hardwareAcceleration: resolved.hardwareAcceleration || accel, optimizeForLatency: true }; + if (opts.description) { + this._codecConfig.description = opts.description; + } 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.currentCodec = codecName; this.frameCount = 0; @@ -239,6 +332,8 @@ class RDVideo { // frame. Drop deltas until one arrives and proactively request one. this._needKeyframe = true; this._decodeInputCount = 0; + this._decodeErrorsSinceFrame = 0; + this._av1DescriptionApplied = false; if (this.onNeedKeyframe) { this.onNeedKeyframe(); } @@ -588,7 +683,24 @@ class RDVideo { // Switch codec if needed 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 @@ -710,6 +822,7 @@ class RDVideo { */ _handleDecodedFrame(frame) { this.frameCount++; + this._decodeErrorsSinceFrame = 0; // Record timestamp so getStats() can report real FPS on the WebCodecs // path (previously only the JMuxer fallback fed this array, so HTTPS // sessions always reported 0 FPS). @@ -742,6 +855,7 @@ class RDVideo { */ _handleError(err) { console.error('[RDVideo] Decoder error:', err && err.message ? err.message : err); + this._decodeErrorsSinceFrame++; if (this.onError) { this.onError(err); } @@ -750,6 +864,15 @@ class RDVideo { 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. const now = performance.now(); if (now - this._lastDecodeErrorTime < 1000) { @@ -761,9 +884,14 @@ class RDVideo { if (!this._softwareRetry) { this._softwareRetry = true; 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._av1DescriptionApplied = false; try { if (this.decoder && this.decoder.state !== 'closed') { @@ -774,15 +902,108 @@ class RDVideo { } const codecName = this.currentCodec; + const savedCodecString = this._codecConfig && this._codecConfig.codec; this.decoder = null; this.initialized = false; 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); + 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 */ @@ -874,6 +1095,8 @@ class RDVideo { this.fallbackMode = false; this.currentCodec = null; this.initialized = false; + this._softwareRetry = false; + this._av1DescriptionApplied = false; } } diff --git a/web-nodejs/public/js/rdclientSecureStore.js b/web-nodejs/public/js/rdclientSecureStore.js index 3e868b7c..aac375d8 100644 --- a/web-nodejs/public/js/rdclientSecureStore.js +++ b/web-nodejs/public/js/rdclientSecureStore.js @@ -6,7 +6,7 @@ var RdClientSecureStore = (function () { 'use strict'; var DB_NAME = 'betterdesk-rdclient'; - var DB_VERSION = 1; + var DB_VERSION = 2; var STORE = 'vault'; var KEY_ID = 'device-key'; 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 { saveCredentials: saveCredentials, loadCredentials: loadCredentials, clearCredentials: clearCredentials, clearStoredPassword: clearStoredPassword, hasStoredPassword: hasStoredPassword, - loadLastUsername: loadLastUsername + loadLastUsername: loadLastUsername, + savePeerPassword: savePeerPassword, + loadPeerPassword: loadPeerPassword, + clearPeerPassword: clearPeerPassword, + clearAllPeerPasswords: clearAllPeerPasswords }; })(); diff --git a/web-nodejs/public/js/remote-dashboard.js b/web-nodejs/public/js/remote-dashboard.js index b71a5f5c..24570aec 100644 --- a/web-nodejs/public/js/remote-dashboard.js +++ b/web-nodejs/public/js/remote-dashboard.js @@ -419,6 +419,7 @@ renderGrid(); showState('grid'); } + syncDesktopViewport(); } function isRdClientDesktop() { @@ -426,12 +427,36 @@ 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() { + syncDesktopViewport(); + ensureSidebarScroll(); if (!isRdClientDesktop()) return; document.documentElement.classList.add('rd-desk-desktop'); document.body.classList.add('rd-desk-desktop'); var app = document.getElementById('rd-desk-app'); if (app) app.classList.add('rd-desk-desktop'); + if (!window.__rdDeskViewportBound) { + window.__rdDeskViewportBound = true; + window.addEventListener('resize', syncDesktopViewport); + } } 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() { if (refreshTimer) clearInterval(refreshTimer); refreshTimer = setInterval(function () { loadAll(true); }, REFRESH_MS); @@ -715,6 +764,8 @@ document.addEventListener('DOMContentLoaded', function () { markDesktopLayout(); + populateLanguageSelect(); + bindDesktopSettings(); renderBrandLogo(); renderUser(); bindUi(); diff --git a/web-nodejs/public/js/remote.js b/web-nodejs/public/js/remote.js index 51a818fe..7b9a067f 100644 --- a/web-nodejs/public/js/remote.js +++ b/web-nodejs/public/js/remote.js @@ -138,6 +138,7 @@ this.connectionOverlay = panel.querySelector('.session-connection-overlay'); this.passwordOverlay = panel.querySelector('.session-password-overlay'); this.passwordInput = panel.querySelector('.session-password-input'); + this.rememberPeerCheckbox = panel.querySelector('.session-remember-peer-checkbox'); this.loginError = panel.querySelector('.session-login-error'); this.statusText = panel.querySelector('.session-status-text'); this.overlayActions = panel.querySelector('.session-overlay-actions'); @@ -545,6 +546,14 @@ session.passwordOverlay.style.display = 'flex'; session.loginError.style.display = 'none'; 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(); }); @@ -583,6 +592,14 @@ session.passwordOverlay.style.display = 'none'; session.tfaOverlay.style.display = 'none'; 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', () => { @@ -1708,7 +1725,21 @@ // ---- 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() { + populateViewerLanguageSelect(); const deviceId = window.__initialDeviceId; const deviceName = window.__initialDeviceName || ''; if (deviceId) { diff --git a/web-nodejs/routes/bd-api.routes.js b/web-nodejs/routes/bd-api.routes.js index 60806394..e52a037b 100644 --- a/web-nodejs/routes/bd-api.routes.js +++ b/web-nodejs/routes/bd-api.routes.js @@ -27,6 +27,7 @@ const express = require('express'); const router = express.Router(); const crypto = require('crypto'); +const config = require('../config/config'); const db = require('../services/database'); const bdRelay = require('../services/bdRelay'); const brandingService = require('../services/brandingService'); @@ -147,6 +148,19 @@ function buildSessionHistory(entries, 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 // --------------------------------------------------------------------------- diff --git a/web-nodejs/routes/generator.routes.js b/web-nodejs/routes/generator.routes.js index 7eaf4dd3..97203cca 100644 --- a/web-nodejs/routes/generator.routes.js +++ b/web-nodejs/routes/generator.routes.js @@ -17,6 +17,7 @@ const { requireAuth, requireAdmin } = require('../middleware/auth'); const keyService = require('../services/keyService'); const bundleService = require('../services/agentBundleService'); const buildWorker = require('../services/agentBuildWorker'); +const rdclientBuildWorker = require('../services/rdclientBuildWorker'); const db = require('../services/database'); const config = require('../config/config'); const brandingService = require('../services/brandingService'); @@ -52,6 +53,7 @@ function serializeBundle(row) { created_at: row.created_at, updated_at: row.updated_at, 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) { 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) { 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], }); } - const normalized = finalizeBundleBranding(base); - normalized.bundle_id = bundleId; - normalized.product_name = normalized.company_name || 'BetterDesk Support'; + const normalized = productType === 'rdclient' + ? { ...base, bundle_id: bundleId, server_url: base.panel_url } + : finalizeBundleBranding(base); + if (productType !== 'rdclient') { + normalized.bundle_id = bundleId; + normalized.product_name = normalized.company_name || 'BetterDesk Support'; + } const brandingHash = bundleService.hashBranding(normalized); const created = await db.createAgentBundle({ bundleId, @@ -218,9 +228,12 @@ router.post('/api/generator/bundles', requireAuth, requireAdmin, async (req, res branding: JSON.stringify(normalized), brandingHash, createdBy: req.session?.userId || null, + productType, }); - // Phase 2: queue installer builds for every supported platform. - buildWorker.enqueueBuildsForHash(brandingHash).catch((e) => { + const enqueue = productType === 'rdclient' + ? rdclientBuildWorker.enqueueBuildsForHash + : buildWorker.enqueueBuildsForHash; + enqueue(brandingHash).catch((e) => { console.error('[generator] enqueue builds failed:', e.message); }); res.json({ success: true, data: { bundle: serializeBundle(created) } }); diff --git a/web-nodejs/scripts/patch-rdclient-i18n.js b/web-nodejs/scripts/patch-rdclient-i18n.js new file mode 100644 index 00000000..e5cde577 --- /dev/null +++ b/web-nodejs/scripts/patch-rdclient-i18n.js @@ -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 l’appareil 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 URL’li RdClient yükleyicileri oluştur', rdclient_server_url_hint: 'BetterDesk konsolunun genel URL’si (/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); +} diff --git a/web-nodejs/server.js b/web-nodejs/server.js index 6338dcfb..d6bba196 100644 --- a/web-nodejs/server.js +++ b/web-nodejs/server.js @@ -526,6 +526,12 @@ async function startServer() { // Start LAN Discovery UDP service 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). // 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); } } + 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) ============ let apiServer = null; diff --git a/web-nodejs/services/agentBuildWorker.js b/web-nodejs/services/agentBuildWorker.js index d01da362..8faea335 100644 --- a/web-nodejs/services/agentBuildWorker.js +++ b/web-nodejs/services/agentBuildWorker.js @@ -592,6 +592,8 @@ async function _claimNextBuild() { return String(a.created_at || '').localeCompare(String(b.created_at || '')); }); 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}`]; if (!profile) continue; await db.upsertAgentBundleBuild({ @@ -615,6 +617,7 @@ async function _listPendingBuilds(limit) { const out = []; for (const b of bundles) { if (b.revoked) continue; + if ((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); diff --git a/web-nodejs/services/agentBundleService.js b/web-nodejs/services/agentBundleService.js index 7b82ecea..b2fe3a65 100644 --- a/web-nodejs/services/agentBundleService.js +++ b/web-nodejs/services/agentBundleService.js @@ -18,6 +18,7 @@ 'use strict'; const crypto = require('crypto'); +const config = require('../config/config'); const conn = require('./agentBundleConnection'); // 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 = { PLATFORMS, SUPPORTED_LANGS, @@ -350,6 +384,7 @@ module.exports = { MAX_SLUG_LENGTH, MIN_SLUG_LENGTH, validateBranding, + validateRdclientBranding, hashBranding, generateBundleId, slugifyName, diff --git a/web-nodejs/services/dbAdapter.js b/web-nodejs/services/dbAdapter.js index aaf8a155..864ddd99 100644 --- a/web-nodejs/services/dbAdapter.js +++ b/web-nodejs/services/dbAdapter.js @@ -920,6 +920,15 @@ function createSqliteAdapter(config) { CREATE INDEX IF NOT EXISTS idx_agent_bundle_builds_status ON agent_bundle_builds (status); `); 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 ---------------------------------------------- @@ -3074,12 +3083,12 @@ function createSqliteAdapter(config) { 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 r = db.prepare(` - INSERT INTO agent_bundles (bundle_id, slug, name, branding, branding_hash, created_by) - VALUES (?, ?, ?, ?, ?, ?) - `).run(bundleId, slug || null, name, branding, brandingHash, createdBy || null); + INSERT INTO agent_bundles (bundle_id, slug, name, branding, branding_hash, created_by, product_type) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(bundleId, slug || null, name, branding, brandingHash, createdBy || null, productType || 'agent'); return db.prepare('SELECT * FROM agent_bundles WHERE id = ?').get(r.lastInsertRowid); }, diff --git a/web-nodejs/services/lanDiscovery.js b/web-nodejs/services/lanDiscovery.js index 03a32ce0..01426bb5 100644 --- a/web-nodejs/services/lanDiscovery.js +++ b/web-nodejs/services/lanDiscovery.js @@ -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 { type: 'betterdesk-announce', version: PROTOCOL_VERSION, @@ -57,10 +63,11 @@ function buildAnnouncement() { version: config.appVersion, port: config.port, apiPort: config.apiPort, - protocol: config.httpsEnabled ? 'https' : 'http', + protocol, publicKey, addresses, discoveryPort: DISCOVERY_PORT, + panelUrl, }, }; } diff --git a/web-nodejs/services/panelDiscovery.js b/web-nodejs/services/panelDiscovery.js new file mode 100644 index 00000000..7ac1d555 --- /dev/null +++ b/web-nodejs/services/panelDiscovery.js @@ -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, +}; diff --git a/web-nodejs/services/rdclientBuildWorker.js b/web-nodejs/services/rdclientBuildWorker.js new file mode 100644 index 00000000..c32994e9 --- /dev/null +++ b/web-nodejs/services/rdclientBuildWorker.js @@ -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, +}; diff --git a/web-nodejs/views/generator.ejs b/web-nodejs/views/generator.ejs index 762c3c60..a2e4ee0a 100644 --- a/web-nodejs/views/generator.ejs +++ b/web-nodejs/views/generator.ejs @@ -25,6 +25,10 @@ add ${_('generator.new_bundle')} +
diff --git a/web-nodejs/views/layouts/remote-desk.ejs b/web-nodejs/views/layouts/remote-desk.ejs index 71692754..726d4b92 100644 --- a/web-nodejs/views/layouts/remote-desk.ejs +++ b/web-nodejs/views/layouts/remote-desk.ejs @@ -27,7 +27,8 @@ cacheVersion: '<%= cacheVersion %>', csrfToken: '<%= typeof csrfToken !== "undefined" ? csrfToken : "" %>', user: <%- JSON.stringify(user || null) %>, - branding: <%- JSON.stringify(branding || {}) %> + branding: <%- JSON.stringify(branding || {}) %>, + availableLanguages: <%- JSON.stringify(availableLanguageList || []) %> }; diff --git a/web-nodejs/views/layouts/viewer.ejs b/web-nodejs/views/layouts/viewer.ejs index 9ad77aca..7082c74e 100644 --- a/web-nodejs/views/layouts/viewer.ejs +++ b/web-nodejs/views/layouts/viewer.ejs @@ -29,7 +29,8 @@ csrfToken: '<%= typeof csrfToken !== 'undefined' ? csrfToken : '' %>', user: <%- JSON.stringify(user || null) %>, serverPubKey: '<%= typeof serverPubKey !== 'undefined' ? serverPubKey : '' %>', - branding: <%- JSON.stringify(branding || {}) %> + branding: <%- JSON.stringify(branding || {}) %>, + availableLanguages: <%- JSON.stringify(availableLanguageList || []) %> }; @@ -37,6 +38,7 @@ + diff --git a/web-nodejs/views/rdclient-login.ejs b/web-nodejs/views/rdclient-login.ejs index 279eef22..a542f61a 100644 --- a/web-nodejs/views/rdclient-login.ejs +++ b/web-nodejs/views/rdclient-login.ejs @@ -19,6 +19,14 @@
+ +
+
diff --git a/web-nodejs/views/remote.ejs b/web-nodejs/views/remote.ejs index 0ec8255b..1fde3779 100644 --- a/web-nodejs/views/remote.ejs +++ b/web-nodejs/views/remote.ejs @@ -32,6 +32,7 @@
+
+