diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml
index 9ae812b6..2b7224d0 100644
--- a/.github/codeql/codeql-config.yml
+++ b/.github/codeql/codeql-config.yml
@@ -58,7 +58,6 @@ query-filters:
paths:
- betterdesk-server/api/auth_handlers.go
- betterdesk-server/main.go
- - betterdesk-support-agent/signalhost/crypto.go
# --- Agent mTLS / self-signed relay in controlled deployments ---
- exclude:
@@ -67,7 +66,6 @@ query-filters:
- betterdesk-agent/agent/agent.go
# API cert pinning uses VerifyPeerCertificate; development-only
# insecure TLS is additionally gated by !release and explicit opt-in.
- - betterdesk-support-agent/apihttp.go
# --- On-prem TLS opt-in (env / tls_strict UI); default remains strict ---
- exclude:
diff --git a/.github/workflows/installer-ci.yml b/.github/workflows/installer-ci.yml
index 6a61e5da..374fdd56 100644
--- a/.github/workflows/installer-ci.yml
+++ b/.github/workflows/installer-ci.yml
@@ -9,7 +9,6 @@ on:
- 'betterdesk.ps1'
- 'betterdesk-docker.sh'
- 'betterdesk-agent/install/**'
- - 'betterdesk-support-agent/**'
- 'scripts/installer-protocol-check.js'
- 'web-nodejs/lib/safePath.js'
- 'web-nodejs/services/updateService.js'
@@ -27,7 +26,6 @@ on:
- 'betterdesk.ps1'
- 'betterdesk-docker.sh'
- 'betterdesk-agent/install/**'
- - 'betterdesk-support-agent/**'
- 'scripts/installer-protocol-check.js'
- 'web-nodejs/lib/safePath.js'
- 'web-nodejs/services/updateService.js'
diff --git a/.github/workflows/support-agent-ci.yml b/.github/workflows/support-agent-ci.yml
deleted file mode 100644
index dea4b4b1..00000000
--- a/.github/workflows/support-agent-ci.yml
+++ /dev/null
@@ -1,141 +0,0 @@
-name: Support Agent CI
-
-on:
- push:
- branches: [main, dev]
- paths:
- - 'betterdesk-support-agent/**'
- - 'betterdesk-agent/**'
- - 'betterdesk-server/protos/**'
- - 'web-nodejs/protos/**'
- - 'docs/important/support-agent-provenance.md'
- - 'docs/important/support-agent-conformance.md'
- - 'THIRD_PARTY_NOTICES.md'
- - 'docs/features/WEB_REMOTE_CLIENT_PLAN.md'
- - '.github/go-server-context.md'
- - '.github/workflows/support-agent-ci.yml'
- - 'web-nodejs/scripts/check-cleanroom-provenance.js'
- - 'web-nodejs/scripts/sync-protocol-schemas.js'
- pull_request:
- paths:
- - 'betterdesk-support-agent/**'
- - 'betterdesk-agent/**'
- - 'betterdesk-server/protos/**'
- - 'web-nodejs/protos/**'
- - 'docs/important/support-agent-provenance.md'
- - 'docs/important/support-agent-conformance.md'
- - 'THIRD_PARTY_NOTICES.md'
- - 'docs/features/WEB_REMOTE_CLIENT_PLAN.md'
- - '.github/go-server-context.md'
- - '.github/workflows/support-agent-ci.yml'
- - 'web-nodejs/scripts/check-cleanroom-provenance.js'
- - 'web-nodejs/scripts/sync-protocol-schemas.js'
-
-permissions:
- contents: read
-
-jobs:
- provenance:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: '24'
- - name: Verify compatibility provenance policy
- run: node web-nodejs/scripts/check-cleanroom-provenance.js && node web-nodejs/scripts/sync-protocol-schemas.js
-
- support-agent:
- name: Support Agent (Linux)
- runs-on: ubuntu-latest
- timeout-minutes: 20
- defaults:
- run:
- working-directory: betterdesk-support-agent
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-go@v5
- with:
- go-version-file: betterdesk-support-agent/go.mod
- cache-dependency-path: betterdesk-support-agent/go.sum
- - name: Install Fyne build dependencies
- run: sudo apt-get update && sudo apt-get install -y libgl1-mesa-dev xorg-dev libwayland-dev libdecor-0-dev
- - name: go vet
- run: go vet ./...
- - name: go test with race detector
- run: go test -race -count=1 -timeout=10m ./...
-
- shared-agent:
- name: Shared Agent (Linux)
- runs-on: ubuntu-latest
- timeout-minutes: 15
- defaults:
- run:
- working-directory: betterdesk-agent
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-go@v5
- with:
- go-version-file: betterdesk-agent/go.mod
- cache-dependency-path: betterdesk-agent/go.sum
- - name: go vet
- run: go vet ./...
- - name: go test with race detector
- run: go test -race -count=1 -timeout=10m ./...
-
- framing-fuzz:
- name: Framing fuzz smoke (${{ matrix.name }})
- runs-on: ubuntu-latest
- timeout-minutes: 10
- strategy:
- fail-fast: false
- matrix:
- include:
- - name: support-agent peer frames
- module: betterdesk-support-agent
- package: ./signalhost
- target: '^FuzzPeerFrameFraming$'
- - name: support-agent Annex-B frames
- module: betterdesk-support-agent
- package: ./signalhost
- target: '^FuzzAnnexBFraming$'
- - name: shared-agent stream frames
- module: betterdesk-agent
- package: ./agent
- target: '^FuzzEncodedStreamFraming$'
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-go@v5
- with:
- go-version-file: ${{ matrix.module }}/go.mod
- cache-dependency-path: ${{ matrix.module }}/go.sum
- - name: Fuzz parser and framing boundary
- working-directory: ${{ matrix.module }}
- run: go test -run '^$' -fuzz '${{ matrix.target }}' -fuzztime=15s -parallel=1 -timeout=2m ${{ matrix.package }}
-
- platform:
- name: Native platform tests (${{ matrix.name }})
- runs-on: ${{ matrix.os }}
- timeout-minutes: 20
- strategy:
- fail-fast: false
- matrix:
- include:
- - name: Windows
- os: windows-latest
- - name: macOS
- os: macos-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-go@v5
- with:
- go-version-file: betterdesk-support-agent/go.mod
- cache-dependency-path: |
- betterdesk-support-agent/go.sum
- betterdesk-agent/go.sum
- - name: Test shared-agent platform implementation
- working-directory: betterdesk-agent
- run: go test -count=1 -timeout=10m ./...
- - name: Test support-agent platform implementation
- working-directory: betterdesk-support-agent
- run: go test -count=1 -timeout=10m ./...
diff --git a/Dockerfile b/Dockerfile
index cf3f0102..fc92629c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -98,22 +98,20 @@ WORKDIR /app
COPY web-nodejs/ .
COPY --from=node-builder /app/node_modules ./node_modules/
-# Support Agent generator source (#391) — panel builds need these trees in-image.
-COPY betterdesk-support-agent /app/betterdesk-support-agent
+# Shared Go trees kept for server tooling / legacy workers (not Support Generator).
+# Support Generator downloads BetterDesk-Client templates into data/modules/.
COPY betterdesk-agent /app/betterdesk-agent
COPY betterdesk-server /app/betterdesk-server
RUN mkdir -p /opt/BetterDeskConsole/agent-source \
- && ln -sfn /app/betterdesk-support-agent /opt/BetterDeskConsole/agent-source/betterdesk-support-agent \
&& ln -sfn /app/betterdesk-agent /opt/BetterDeskConsole/agent-source/betterdesk-agent \
&& ln -sfn /app/betterdesk-server /opt/BetterDeskConsole/agent-source/betterdesk-server \
- && chown -R betterdesk:betterdesk /app/betterdesk-support-agent /app/betterdesk-agent /app/betterdesk-server /opt/BetterDeskConsole
+ && chown -R betterdesk:betterdesk /app/betterdesk-agent /app/betterdesk-server /opt/BetterDeskConsole
ARG BETTERDESK_COMMIT_SHA=unknown
ARG BETTERDESK_IMAGE_VERSION=unknown
ENV BETTERDESK_IMAGE_SHA=${BETTERDESK_COMMIT_SHA}
ENV BETTERDESK_IMAGE_VERSION=${BETTERDESK_IMAGE_VERSION}
ENV BETTERDESK_UPDATE_MODE=image
-ENV AGENT_SOURCE_DIR=/app/betterdesk-support-agent
RUN printf '%s\n' "${BETTERDESK_COMMIT_SHA}" > /app/.image-commit
# ---- Supervisord config ----
diff --git a/Dockerfile.console b/Dockerfile.console
index 6f1bf92d..71d3153e 100644
--- a/Dockerfile.console
+++ b/Dockerfile.console
@@ -48,12 +48,10 @@ RUN apk add --no-cache \
COPY web-nodejs/ .
COPY --from=build /app/node_modules ./node_modules/
-# Support Agent generator source (#391)
-COPY betterdesk-support-agent /app/betterdesk-support-agent
+# Shared Go trees kept for server tooling / legacy workers (not Support Generator).
COPY betterdesk-agent /app/betterdesk-agent
COPY betterdesk-server /app/betterdesk-server
RUN mkdir -p /opt/BetterDeskConsole/agent-source \
- && ln -sfn /app/betterdesk-support-agent /opt/BetterDeskConsole/agent-source/betterdesk-support-agent \
&& ln -sfn /app/betterdesk-agent /opt/BetterDeskConsole/agent-source/betterdesk-agent \
&& ln -sfn /app/betterdesk-server /opt/BetterDeskConsole/agent-source/betterdesk-server
@@ -62,7 +60,6 @@ ARG BETTERDESK_IMAGE_VERSION=unknown
ENV BETTERDESK_IMAGE_SHA=${BETTERDESK_COMMIT_SHA}
ENV BETTERDESK_IMAGE_VERSION=${BETTERDESK_IMAGE_VERSION}
ENV BETTERDESK_UPDATE_MODE=image
-ENV AGENT_SOURCE_DIR=/app/betterdesk-support-agent
RUN printf '%s\n' "${BETTERDESK_COMMIT_SHA}" > /app/.image-commit
# Copy entrypoint scripts
diff --git a/betterdesk-support-agent/README.md b/betterdesk-support-agent/README.md
deleted file mode 100644
index 7d387d18..00000000
--- a/betterdesk-support-agent/README.md
+++ /dev/null
@@ -1,185 +0,0 @@
-# BetterDesk Support Agent
-
-A lightweight quick-help remote desktop agent built as a **single self-contained
-Go binary**. The default interface is Wails/WebView2 on Windows (with a legacy
-Fyne fallback); one codebase produces two distribution forms:
-
-| Form | How it runs | Autostart | State location |
-|------|-------------|-----------|----------------|
-| **Installer** | `betterdesk-support -install` then launches at login | XDG autostart / HKCU Run / LaunchAgent | per-user config dir |
-| **Portable** | run the binary directly, no install | none | `data/` next to the binary (with a `portable` marker file) |
-
-## Product identity and compatibility
-
-BetterDesk Support Agent is a BetterDesk product and a passive remote-support
-target. It does not provide an outbound “connect to peer” workflow.
-
-Some releases include a desktop-client wire compatibility surface so approved
-operators can connect to the Support Agent. That surface is being isolated
-behind a BetterDesk compatibility adapter and is subject to the provenance gate
-in [`../docs/important/support-agent-provenance.md`](../docs/important/support-agent-provenance.md).
-Until that gate is complete, do not describe the compatibility component as a
-fork, clone, clean-room implementation, or independently licensable component.
-
-The remote-desktop engine is reused from the shared `betterdesk-agent` module,
-so the support agent offers the same remote-desktop capabilities while exposing
-only a minimal "quick help" surface.
-
-## Quick-help UI
-
-- **Your ID** — stable per-machine device ID (copy to clipboard).
-- **Access password** — shown/hidden, copy, regenerate, or set a custom one.
-- **Access mode** — *Ask each time* (supervised), *Unattended*, or *Disabled*.
-- **Request help** — posts to the console `/api/bd/help-request` endpoint.
-- **Test connection** — self-tests reachability of the CDAP gateway
- (`:21122/cdap/health`) and the web console (`:5000/health`) and reports each.
-- **System tray** — keeps running in the background; closing the window hides
- it, and the tray menu can reopen the agent, request help, or exit.
-- **Windows app icon** — generated from the signed branding profile and embedded
- into the portable EXE and MSI, so it appears in the taskbar, notification area,
- Explorer, and installed-apps list.
-
-## Device-list marking
-
-The agent registers with `device_type = os_agent` (required for remote-session
-routing and accepted by the server's type validation) and carries identifying
-**tags** that show up in the console device list:
-
-- `support-agent` — always present, identifies this build.
-- `portable` or `installed` — distinguishes the portable single-binary form
- from an installed copy.
-
-## State encryption & anti-impersonation
-
-Local state (device identity + access password) is stored **encrypted at rest**
-with AES-256-GCM. The key is derived from a platform-stable machine identifier
-(`/etc/machine-id` on Linux, `COMPUTERNAME` on Windows, hostname fallback) and a
-domain-separation label, and never leaves the machine.
-
-Because the key is machine-bound, a `state.json` copied to another machine fails
-to decrypt; the agent then regenerates a fresh identity instead of cloning the
-original device, preventing impersonation. Legacy plaintext state files are
-migrated to the encrypted format on first load. The access password is still
-shown to the local user through the UI (decrypted in memory on demand); the file
-is additionally written with `0600` permissions.
-
-
-## Branding
-
-Appearance and connection details are **baked at build time** by the Console
-Generator into `resources/branding.json` (embedded via `go:embed`). Release
-builds require an Ed25519-signed profile; the matching public key is embedded
-in the binary and any verification, expiry, or endpoint-allowlist failure
-disables its connection profile. `BETTERDESK_BUNDLE_SIGNING_KEY_FILE` is
-required for every distributed build. The legacy AES seal is accepted only by
-non-release developer builds and is obfuscation, not a trust boundary. Fields:
-`product_name`, `company_name`, `tagline`,
-`support_email`, `primary_color`, `accent_color`, `logo_data_url`,
-`default_language`, `allow_unattended`, `capabilities`, `server_address`,
-`server_key`, `bundle_id`, `profile_issued_at`, `profile_expires_at`,
-`allowed_endpoints`, and nested
-`server { address, api_url, public_key, cert_pin, cdap_url }`.
-
-Transport may be **HTTPS/WSS** (recommended on the public internet) or
-**HTTP/WS** for LAN/IP deployments, matching the RustDesk model: management
-and CDAP can use plaintext HTTP/WebSocket while remote-session crypto stays on
-the signal/relay protocol layer. The signed `allowed_endpoints` list still
-binds the agent to the baked URLs.
-
-Optional build hardening:
-
-```bash
-BETTERDESK_USE_GARBLE=1 ./build.sh -b /tmp/branding.json # needs garble in PATH
-BETTERDESK_USE_UPX=1 ./build.sh -p windows # opt-in; may trip AV
-```
-
-Override for local testing without rebuilding (non-release builds only):
-
-```bash
-BETTERDESK_AGENT_BRANDING=/path/to/branding.json ./betterdesk-support
-```
-
-## Connection resilience
-
-The agent remembers last-known healthy endpoint metadata in encrypted local
-state. Distributed builds use only the HTTPS/WSS endpoints explicitly allowed
-by their signed profile; they never downgrade to HTTP/WS after a failure.
-
-## Build
-
-```bash
-# Generate or provide an Ed25519 PKCS#8 key outside the workspace, then build.
-BETTERDESK_BUNDLE_SIGNING_KEY_FILE=/secure/path/branding-ed25519.pem \
- ./build.sh
-
-# With a generated branding profile
-BETTERDESK_BUNDLE_SIGNING_KEY_FILE=/secure/path/branding-ed25519.pem \
- ./build.sh -b /tmp/branding.json -o dist/acme-support
-
-# Windows target (needs mingw-w64 CGO toolchain)
-./build.sh -p windows
-```
-
-### Linux build dependencies
-
-Linux builds ship **two UI binaries** (X11 and Wayland) plus a launcher that picks
-the right one for the current session. Build with `./build.sh -p linux -d`.
-
-```bash
-# Fedora
-sudo dnf install -y libXxf86vm-devel libXcursor-devel libXrandr-devel \
- libXinerama-devel libXi-devel mesa-libGL-devel wayland-devel libdecor-devel
-# Debian/Ubuntu
-sudo apt install -y libgl1-mesa-dev xorg-dev libwayland-dev libdecor-0-dev
-```
-
-Force a backend: `BETTERDESK_UI_BACKEND=wayland` or `=x11`.
-
-## Install / uninstall
-
-```bash
-./betterdesk-support -install # copy to per-user dir + enable autostart
-./betterdesk-support -uninstall # remove autostart + installed binary
-```
-
-## System requirements
-
-| Platform | Minimum |
-|----------|---------|
-| Windows | 10 / Server 2016+ (64-bit) with WebView2. Legacy Fyne builds require OpenGL 2.0+; use Mesa companion DLL or `-nogui` on VMs/RDP. |
-| Linux | glibc-based distros with X11 or Wayland; dual UI binaries included. AppImage, deb, rpm, portable tar supported. |
-| macOS | 11+ (experimental cross-compile) |
-
-Portable and installed builds behave identically except for state location and autostart. A portable binary added to autostart hides to the tray on close like the installed build.
-
-## Portable usage
-
-**Tarball / bare binary:** place an empty `portable` (or `.portable`) file next to
-the binary; state is written to a `data/` folder beside it.
-
-**AppImage:** no marker needed — the runtime sets `APPIMAGE` and state is stored
-in `betterdesk-support-data/` next to the `.AppImage` file (the mount is read-only).
-
-### Windows without OpenGL (VM / RDP)
-
-Fyne needs a working OpenGL 2.0+ driver (WGL). If you see
-`WGL: The driver does not appear to support OpenGL`:
-
-1. Update the graphics driver, or install
- [OpenGL Compatibility Pack](https://apps.microsoft.com/detail/9nqpsl29bfff) from Microsoft Store.
-2. Or run without GUI (remote engine only):
-
-```bat
-betterdesk-support.exe -nogui
-```
-
-Supervised consent prompts require the GUI; use unattended access mode for `-nogui`.
-
-## Environment variables
-
-| Variable | Effect |
-|----------|--------|
-| `BETTERDESK_AGENT_BRANDING` | Load branding from an external JSON file |
-| `BETTERDESK_AGENT_DATA_DIR` | Force the state directory |
-| `BETTERDESK_CDAP_TLS=1` | Enable TLS in non-release developer profiles |
-| `BETTERDESK_AGENT_INSECURE_TLS=1` | Non-release-only local self-signed test override; ignored by release binaries |
diff --git a/betterdesk-support-agent/access_policy.go b/betterdesk-support-agent/access_policy.go
deleted file mode 100644
index f148d2ae..00000000
--- a/betterdesk-support-agent/access_policy.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package main
-
-import "strings"
-
-// incomingCapabilities adapts the enforceable Support Agent feature policy to
-// the switches exposed by the shared CDAP engine and signal host.
-type incomingCapabilities struct {
- Desktop bool
- Files bool
- Clipboard bool
- Audio bool
- Terminal bool
- Restart bool
-}
-
-func (b Branding) incomingCapabilities() incomingCapabilities {
- policy := hostCapabilityPolicyFor(b)
- return incomingCapabilities{
- Desktop: policy.allows(hostFeatureScreenView) && policy.allows(hostFeatureInput),
- Files: policy.allows(hostFeatureFiles),
- Clipboard: policy.allows(hostFeatureClipboard),
- Audio: policy.allows(hostFeatureAudio),
- Terminal: policy.allows(hostFeatureTerminal),
- Restart: policy.allows(hostFeatureRestart),
- }
-}
-
-// incomingAccessPolicy combines the user-selected access mode with the
-// immutable bundle policy. Treat unsupported or stale unattended settings as
-// supervised rather than granting an unattended session.
-type incomingAccessPolicy struct {
- mode string
- passwordConfigured bool
- unattended bool
- capabilities incomingCapabilities
-}
-
-func accessPolicyFor(b Branding, st *AppState) incomingAccessPolicy {
- _, mode, password, _ := st.Snapshot()
- return incomingAccessPolicy{
- mode: mode,
- passwordConfigured: strings.TrimSpace(password) != "",
- unattended: mode == AccessUnattended && b.AllowUnattended,
- capabilities: b.incomingCapabilities(),
- }
-}
-
-// allowsUnattended is the complete local unattended-access predicate. A
-// branded unattended mode without a usable local password must fail closed;
-// otherwise CDAP and relay could disagree about whether a session is allowed.
-func (p incomingAccessPolicy) allowsUnattended() bool {
- return p.unattended && p.passwordConfigured
-}
-
-func (p incomingAccessPolicy) requiresConsent() bool {
- return !p.allowsUnattended()
-}
-
-func (p incomingAccessPolicy) allowsSignalHost(headless bool) bool {
- if p.mode == AccessDisabled || !p.capabilities.Desktop || !p.passwordConfigured {
- return false
- }
- return !headless || p.allowsUnattended()
-}
-
-func (p incomingAccessPolicy) signalHostDisabledReason(headless bool) string {
- switch {
- case p.mode == AccessDisabled:
- return "access mode is disabled"
- case !p.capabilities.Desktop:
- return "desktop capability is disabled by branding"
- case !p.passwordConfigured:
- return "no local access password is configured"
- case headless && p.mode == AccessUnattended && !p.unattended:
- return "unattended access is disabled by branding"
- case headless:
- return "headless mode has no local consent UI; approved unattended access is required"
- default:
- return ""
- }
-}
diff --git a/betterdesk-support-agent/access_policy_test.go b/betterdesk-support-agent/access_policy_test.go
deleted file mode 100644
index 1c1e6679..00000000
--- a/betterdesk-support-agent/access_policy_test.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package main
-
-import "testing"
-
-func boolRef(v bool) *bool {
- return &v
-}
-
-func TestHeadlessConsentRequiresApprovedUnattendedAccess(t *testing.T) {
- st := &AppState{
- DeviceID: "BD-TEST",
- AccessMode: AccessUnattended,
- AccessPassword: "secret12",
- }
-
- if headlessConsent(Branding{}, st)("session", "operator") {
- t.Fatal("unattended mode must not bypass branding approval")
- }
- if !headlessConsent(Branding{AllowUnattended: true}, st)("session", "operator") {
- t.Fatal("approved unattended mode should be allowed in headless mode")
- }
-
- st.AccessMode = AccessSupervised
- if headlessConsent(Branding{AllowUnattended: true}, st)("session", "operator") {
- t.Fatal("supervised mode must be denied without a local UI")
- }
-
- st.AccessMode = AccessDisabled
- if headlessConsent(Branding{AllowUnattended: true}, st)("session", "operator") {
- t.Fatal("disabled access mode must never be allowed")
- }
-}
-
-func TestUnattendedPolicyRequiresUsablePassword(t *testing.T) {
- st := &AppState{
- DeviceID: "BD-TEST",
- AccessMode: AccessUnattended,
- AccessPassword: " \t ",
- }
- brand := Branding{AllowUnattended: true}
- policy := accessPolicyFor(brand, st)
-
- if policy.allowsUnattended() {
- t.Fatal("whitespace-only password must not enable unattended access")
- }
- if !policy.requiresConsent() {
- t.Fatal("passwordless unattended policy must require consent")
- }
- if policy.allowsSignalHost(true) {
- t.Fatal("passwordless unattended policy must not expose a headless relay")
- }
- if headlessConsent(brand, st)("session", "operator") {
- t.Fatal("headless CDAP consent must not bypass a missing password")
- }
-}
-
-func TestSignalHostPolicyDisablesUnsafeModes(t *testing.T) {
- st := &AppState{
- DeviceID: "BD-TEST",
- AccessMode: AccessSupervised,
- AccessPassword: "secret12",
- }
- brand := Branding{AllowUnattended: true}
-
- if !accessPolicyFor(brand, st).allowsSignalHost(false) {
- t.Fatal("supervised GUI mode should permit a consent-gated signal host")
- }
- if accessPolicyFor(brand, st).allowsSignalHost(true) {
- t.Fatal("supervised headless mode must not expose a signal host")
- }
-
- st.AccessMode = AccessDisabled
- if accessPolicyFor(brand, st).allowsSignalHost(false) {
- t.Fatal("disabled mode must not expose a signal host")
- }
-
- st.AccessMode = AccessUnattended
- if !accessPolicyFor(brand, st).allowsSignalHost(true) {
- t.Fatal("approved unattended headless mode should expose a signal host")
- }
-
- brand.Capabilities = &CapabilityFlags{Desktop: boolRef(false)}
- if accessPolicyFor(brand, st).allowsSignalHost(false) {
- t.Fatal("desktop-disabled branding must not expose a signal host")
- }
-}
-
-func TestNewSignalHostSupportsOnlySafeHeadlessMode(t *testing.T) {
- st := &AppState{
- DeviceID: "BD-TEST",
- AccessMode: AccessUnattended,
- AccessPassword: "secret12",
- }
- brand := Branding{
- ServerAddress: "https://desk.example.test",
- AllowUnattended: true,
- }
- host, reason := newSignalHost(brand, st, true, signalHostCallbacks{})
- if host == nil || reason != "" {
- t.Fatalf("safe headless host = %v, reason = %q", host, reason)
- }
-
- st.AccessMode = AccessDisabled
- host, reason = newSignalHost(brand, st, true, signalHostCallbacks{})
- if host != nil || reason == "" {
- t.Fatalf("disabled headless host = %v, reason = %q", host, reason)
- }
-}
-
-func TestIncomingCapabilitiesIncludeAudioAndRestartPolicy(t *testing.T) {
- caps := Branding{Capabilities: &CapabilityFlags{
- Audio: boolRef(false),
- Restart: boolRef(false),
- }}.incomingCapabilities()
- if caps.Audio || caps.Restart {
- t.Fatalf("audio=%v restart=%v, want both disabled", caps.Audio, caps.Restart)
- }
-}
diff --git a/betterdesk-support-agent/apihttp.go b/betterdesk-support-agent/apihttp.go
deleted file mode 100644
index 0884c46a..00000000
--- a/betterdesk-support-agent/apihttp.go
+++ /dev/null
@@ -1,206 +0,0 @@
-package main
-
-import (
- "bytes"
- "context"
- "crypto/sha256"
- "crypto/subtle"
- "crypto/tls"
- "crypto/x509"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "os"
- "strings"
- "time"
-)
-
-const defaultAPIPort = 21114
-
-func tlsInsecureEnabled() bool {
- // A release binary must never allow an environment variable to downgrade
- // certificate verification. Development builds may opt in for local
- // self-signed test servers only.
- return !isReleaseBuild() && os.Getenv("BETTERDESK_AGENT_INSECURE_TLS") == "1"
-}
-
-// apiHTTPClient returns an HTTP client for BetterDesk API calls.
-func apiHTTPClient(timeout time.Duration) *http.Client {
- pin := ""
- if b := GetBranding(); b.Server != nil {
- pin = b.Server.CertPin
- }
- return apiHTTPClientWithPin(timeout, pin)
-}
-
-func apiHTTPClientWithPin(timeout time.Duration, pin string) *http.Client {
- client := &http.Client{Timeout: timeout}
- pin = normalizeServerCertPin(pin)
- if pin == "" && !tlsInsecureEnabled() {
- return client
- }
-
- tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
- if pin != "" {
- // Pinning the leaf SPKI is an authentication check stronger than
- // platform trust alone. The profile's endpoint allowlist prevents this
- // key from being used for an arbitrary destination.
- tlsConfig.InsecureSkipVerify = true //nolint:gosec // verified below
- tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
- if len(rawCerts) == 0 {
- return fmt.Errorf("tls: server presented no certificate")
- }
- leaf, err := x509.ParseCertificate(rawCerts[0])
- if err != nil {
- return fmt.Errorf("tls: parse leaf certificate: %w", err)
- }
- sum := sha256.Sum256(leaf.RawSubjectPublicKeyInfo)
- got := hex.EncodeToString(sum[:])
- if subtle.ConstantTimeCompare([]byte(got), []byte(pin)) != 1 {
- return fmt.Errorf("tls: server public-key pin mismatch")
- }
- return nil
- }
- } else {
- // Development-only self-signed test mode. tlsInsecureEnabled() cannot
- // become true in a release build.
- tlsConfig.InsecureSkipVerify = true //nolint:gosec // opt-in development mode
- }
- client.Transport = &http.Transport{TLSClientConfig: tlsConfig}
- return client
-}
-
-func normalizeServerCertPin(pin string) string {
- pin = strings.ToLower(strings.TrimSpace(pin))
- pin = strings.NewReplacer("sha256:", "", ":", "", " ", "", "\t", "", "\n", "").Replace(pin)
- if len(pin) != sha256.Size*2 {
- return ""
- }
- if _, err := hex.DecodeString(pin); err != nil {
- return ""
- }
- return pin
-}
-
-// apiBaseURL resolves the Go server API base (…/api) from branding.
-func apiBaseURL(b Branding) string {
- if b.Server != nil && strings.TrimSpace(b.Server.APIURL) != "" {
- u := strings.TrimRight(strings.TrimSpace(b.Server.APIURL), "/")
- if strings.HasSuffix(u, "/api") {
- return u
- }
- return u + "/api"
- }
- scheme := schemeFromAddr(b.ServerAddress)
- if b.useTLS() {
- scheme = "https"
- }
- host := hostFromAddr(b.ServerAddress)
- return fmt.Sprintf("%s://%s:%d/api", scheme, host, defaultAPIPort)
-}
-
-// apiJSON performs a JSON HTTP request against the BetterDesk API.
-func apiJSON(method, apiURL string, body any, out any) (int, error) {
- if err := validateAPIEndpoint(apiURL); err != nil {
- return 0, err
- }
- var reader io.Reader
- if body != nil {
- data, err := json.Marshal(body)
- if err != nil {
- return 0, err
- }
- reader = bytes.NewReader(data)
- }
- ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
- defer cancel()
-
- req, err := http.NewRequestWithContext(ctx, method, apiURL, reader)
- if err != nil {
- return 0, err
- }
- if body != nil {
- req.Header.Set("Content-Type", "application/json")
- }
-
- resp, err := apiHTTPClient(22 * time.Second).Do(req)
- if err != nil {
- // #region agent log
- debugLog("H2", "apihttp.go:apiJSON", "http request failed", map[string]any{
- "method": method, "url": apiURL, "error": err.Error(),
- "insecure_tls": tlsInsecureEnabled(),
- })
- // #endregion
- return 0, err
- }
- defer resp.Body.Close()
-
- raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
- if err != nil {
- return resp.StatusCode, err
- }
- if out != nil && len(raw) > 0 {
- if err := json.Unmarshal(raw, out); err != nil {
- // #region agent log
- debugLog("H2", "apihttp.go:apiJSON", "json decode failed", map[string]any{
- "method": method, "url": apiURL, "status": resp.StatusCode,
- "body_len": len(raw), "error": err.Error(),
- })
- // #endregion
- return resp.StatusCode, fmt.Errorf("invalid JSON: %w", err)
- }
- }
- if resp.StatusCode >= 400 {
- // #region agent log
- debugLog("H2", "apihttp.go:apiJSON", "http error status", map[string]any{
- "method": method, "url": apiURL, "status": resp.StatusCode, "body_len": len(raw),
- })
- // #endregion
- }
- return resp.StatusCode, nil
-}
-
-func validateAPIEndpoint(apiURL string) error {
- u, err := url.Parse(apiURL)
- if err != nil || u.Host == "" {
- return fmt.Errorf("invalid BetterDesk API endpoint")
- }
- // http and https are both valid; release builds bind the exact URL via the
- // signed allowed_endpoints list (RustDesk-style HTTP for LAN is supported).
- if u.Scheme != "http" && u.Scheme != "https" {
- return fmt.Errorf("unsupported BetterDesk API endpoint scheme")
- }
- return nil
-}
-
-// normalizeServerOrigin stores a canonical https?://host:port origin in branding address.
-func normalizeServerOrigin(addr string) string {
- addr = strings.TrimSpace(addr)
- if addr == "" {
- return addr
- }
- withScheme := addr
- if !strings.HasPrefix(addr, "http://") && !strings.HasPrefix(addr, "https://") {
- withScheme = "http://" + addr
- }
- u, err := url.Parse(withScheme)
- if err != nil || u.Host == "" {
- return addr
- }
- port := u.Port()
- if port == "" {
- if u.Scheme == "https" {
- port = "443"
- } else {
- port = "80"
- }
- }
- host := u.Hostname()
- if strings.Contains(host, ":") {
- host = "[" + host + "]"
- }
- return fmt.Sprintf("%s://%s:%s", u.Scheme, host, port)
-}
diff --git a/betterdesk-support-agent/apihttp_test.go b/betterdesk-support-agent/apihttp_test.go
deleted file mode 100644
index 01080723..00000000
--- a/betterdesk-support-agent/apihttp_test.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package main
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-)
-
-func TestInsecureTLSIsDevelopmentOnly(t *testing.T) {
- t.Setenv("BETTERDESK_AGENT_INSECURE_TLS", "1")
- if got, want := tlsInsecureEnabled(), !isReleaseBuild(); got != want {
- t.Fatalf("tlsInsecureEnabled() = %v, want %v (release=%v)", got, want, isReleaseBuild())
- }
-}
-
-func TestAPIHTTPClientDoesNotDisableVerificationInRelease(t *testing.T) {
- t.Setenv("BETTERDESK_AGENT_INSECURE_TLS", "1")
- client := apiHTTPClient(time.Second)
- transport, ok := client.Transport.(*http.Transport)
-
- if isReleaseBuild() {
- if ok && transport.TLSClientConfig != nil && transport.TLSClientConfig.InsecureSkipVerify {
- t.Fatal("release HTTP client disabled TLS verification")
- }
- return
- }
- if !ok || transport.TLSClientConfig == nil || !transport.TLSClientConfig.InsecureSkipVerify {
- t.Fatal("development opt-in did not configure insecure TLS")
- }
-}
-
-func TestHTTPGetKeepsVerificationEnabledUnlessDevelopmentOptIn(t *testing.T) {
- t.Setenv("BETTERDESK_AGENT_INSECURE_TLS", "1")
- client := healthHTTPClient("https://desk.example.test/health")
- transport, ok := client.Transport.(*http.Transport)
- if isReleaseBuild() && ok && transport.TLSClientConfig != nil && transport.TLSClientConfig.InsecureSkipVerify {
- t.Fatal("release health probe would disable TLS verification")
- }
- if !isReleaseBuild() && (!ok || transport.TLSClientConfig == nil || !transport.TLSClientConfig.InsecureSkipVerify) {
- t.Fatal("development health probe did not configure insecure TLS")
- }
-}
-
-func TestAPIHTTPClientWithPinVerifiesServerSPKI(t *testing.T) {
- server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNoContent)
- }))
- defer server.Close()
-
- sum := sha256.Sum256(server.Certificate().RawSubjectPublicKeyInfo)
- pin := hex.EncodeToString(sum[:])
-
- response, err := apiHTTPClientWithPin(time.Second, pin).Get(server.URL)
- if err != nil {
- t.Fatalf("pinned request failed: %v", err)
- }
- _ = response.Body.Close()
-
- if _, err := apiHTTPClientWithPin(time.Second, strings.Repeat("0", 64)).Get(server.URL); err == nil {
- t.Fatal("mismatched server pin unexpectedly succeeded")
- }
-}
-
-func TestNormalizeServerCertPin(t *testing.T) {
- pin := "sha256:AA:BB " + strings.Repeat("0", 60)
- if got := normalizeServerCertPin(pin); got != "aabb"+strings.Repeat("0", 60) {
- t.Fatalf("normalized pin = %q", got)
- }
- if got := normalizeServerCertPin("not-a-pin"); got != "" {
- t.Fatalf("invalid pin normalized to %q", got)
- }
-}
diff --git a/betterdesk-support-agent/app.go b/betterdesk-support-agent/app.go
deleted file mode 100644
index 4df87dac..00000000
--- a/betterdesk-support-agent/app.go
+++ /dev/null
@@ -1,501 +0,0 @@
-//go:build fyneui
-
-package main
-
-import (
- "log"
- "sync"
- "time"
-
- "fyne.io/fyne/v2"
- "fyne.io/fyne/v2/app"
- "fyne.io/fyne/v2/canvas"
- "fyne.io/fyne/v2/container"
- "fyne.io/fyne/v2/dialog"
- "fyne.io/fyne/v2/driver/desktop"
- "fyne.io/fyne/v2/theme"
- "fyne.io/fyne/v2/widget"
-
- "github.com/unitronix/betterdesk-support-agent/signalhost"
-)
-
-// ui holds the long-lived application objects shared across the window.
-type ui struct {
- app fyne.App
- win fyne.Window
- brand Branding
- state *AppState
- engine *Engine
- overlay *sessionOverlay
- pwShown bool
- pwValueLbl *widget.Label
- pwBox fyne.CanvasObject
- statusLbl *widget.Label
- statusDot *canvas.Rectangle
- consentCh chan consentRequest
- chatMessages []string
- chatWindow fyne.Window
- signalHostMu sync.Mutex
- signalHost *signalhost.Host
-}
-
-// run boots the GUI.
-func run() {
- brand := GetBranding()
-
- st, err := LoadState()
- if err != nil {
- log.Fatalf("[support-agent] state: %v", err)
- }
- setLang(st.Language)
-
- a := app.NewWithID("com.betterdesk.supportagent")
- a.Settings().SetTheme(newBrandedTheme(brand))
- if icon := brand.LogoResource(); icon != nil {
- a.SetIcon(icon)
- }
-
- u := &ui{
- app: a,
- brand: brand,
- state: st,
- engine: NewEngine(version),
- consentCh: make(chan consentRequest, 1),
- }
- u.overlay = newSessionOverlay(a, brand.ProductName, u.disconnectActiveSessions)
-
- u.engine.SetCallbacks(u.handleConsent, u.handleSessionStart, u.handleSessionEnd)
- u.engine.SetChatHandler(u.handleChatMessage)
-
- u.win = a.NewWindow(brand.ProductName + " — " + t("window_title"))
- if icon := brand.LogoResource(); icon != nil {
- u.win.SetIcon(icon)
- }
- const winW, winH float32 = 480, 720
- u.win.SetContent(u.buildMainLayout())
- u.win.Resize(fyne.NewSize(winW, winH))
- u.win.SetFixedSize(true)
- u.setupTray()
-
- go u.consentLoop()
-
- if brand.HasConnection() {
- u.bootstrapConnection()
- }
-
- log.Printf("[support-agent] %s starting (device=%s)", version, st.DeviceID)
- appLogInfo("startup", "support agent started", map[string]any{"device_id": st.DeviceID, "version": version})
- u.win.ShowAndRun()
-}
-
-func (u *ui) bootstrapConnection() {
- go func() {
- // #region agent log
- debugLog("H1", "app.go:bootstrapConnection", "branding connection profile", map[string]any{
- "server_address": u.brand.ServerAddress,
- "api_base": apiBaseURL(u.brand),
- "cdap_health": u.brand.CDAPHealthURL(),
- "cdap_ws": u.brand.CDAPWebSocketURL(),
- "use_https": u.brand.UseHTTPS,
- "sends_token": false,
- "branding_embed_has_token": brandingEmbedHasLegacyToken(),
- "bundle_id": u.brand.BundleID,
- "device_id": u.state.DeviceID,
- })
- // #endregion
- res, err := EnsureEnrolled(u.brand, u.state, version)
- if err != nil {
- log.Printf("[support-agent] enrollment: %v", err)
- // #region agent log
- debugLog("H2", "app.go:bootstrapConnection", "enrollment failed", map[string]any{"error": err.Error()})
- // #endregion
- u.applyStatus(statusKindError, t("enrollment_error")+" — "+shortenErr(err.Error()))
- return
- }
- // #region agent log
- debugLog("H4", "app.go:bootstrapConnection", "enrollment result", map[string]any{
- "status": res.Status, "token_len": len(res.DeviceToken), "message": res.Message,
- "is_enrolled": u.state.IsEnrolled(),
- })
- // #endregion
- u.onEnrollmentUpdate(res)
- if res.Status == EnrollmentPending {
- StartEnrollmentPoll(u.brand, u.state, version, 5*time.Second, u.onEnrollmentUpdate)
- }
- u.startStatusLoop()
- }()
-}
-
-func (u *ui) onEnrollmentUpdate(res EnrollmentStatus) {
- switch res.Status {
- case EnrollmentApproved:
- if !u.state.IsEnrolled() {
- u.applyStatus(statusKindPending, t("enrollment_pending"))
- return
- }
- if u.engine.Running() {
- u.applyStatus(statusKindConnected, t("connected"))
- } else {
- u.applyStatus(statusKindPending, t("disconnected"))
- }
- if err := SyncAccessPassword(u.brand, u.state); err != nil {
- log.Printf("[support-agent] access password sync: %v", err)
- }
- if !u.engine.Running() {
- if err := u.engine.Start(u.state); err != nil {
- log.Printf("[support-agent] engine start: %v", err)
- return
- }
- }
- if u.engine.Running() {
- u.startSignalHost()
- }
- case EnrollmentPending:
- u.stopRemoteAccessForEnrollmentState()
- msg := t("enrollment_pending")
- if res.Message != "" {
- msg = res.Message
- }
- u.applyStatus(statusKindPending, msg)
- case EnrollmentRejected:
- u.stopRemoteAccessForEnrollmentState()
- u.applyStatus(statusKindError, t("enrollment_rejected"))
- default:
- u.applyStatus(statusKindPending, t("disconnected"))
- }
-}
-
-func (u *ui) stopRemoteAccessForEnrollmentState() {
- if u.engine != nil {
- u.engine.Stop()
- }
- u.stopSignalHost()
-}
-
-func (u *ui) handleConsent(sessionID, operator string) bool {
- resp := make(chan bool, 1)
- req := consentRequest{sessionID: sessionID, operator: operator, response: resp}
- select {
- case u.consentCh <- req:
- default:
- return false
- }
- select {
- case granted := <-resp:
- return granted
- case <-time.After(30 * time.Second):
- return false
- }
-}
-
-func (u *ui) consentLoop() {
- for req := range u.consentCh {
- granted := false
- done := make(chan struct{})
- msg := t("consent_prompt") + " " + req.operator
- acceptBtn := widget.NewButton(t("consent_accept"), func() {
- granted = true
- close(done)
- })
- acceptBtn.Importance = widget.HighImportance
- denyBtn := widget.NewButton(t("consent_deny"), func() {
- close(done)
- })
- body := container.NewVBox(
- widget.NewLabel(msg),
- container.NewGridWithColumns(2, acceptBtn, denyBtn),
- )
- d := dialog.NewCustom(t("consent_title"), t("cancel"), body, u.win)
- d.Show()
- <-done
- d.Hide()
- req.response <- granted
- appLogInfo("consent", "remote access consent answered", map[string]any{
- "session_id": req.sessionID, "operator": req.operator, "granted": granted,
- })
- }
-}
-
-func (u *ui) handleSessionStart(sessionID, operator, mode string) {
- u.overlay.show(operator, mode)
-}
-
-func (u *ui) handleSessionEnd(sessionID string) {
- u.overlay.hide()
-}
-
-// disconnectActiveSessions is invoked by the local session overlay. The shared
-// CDAP engine does not expose a per-session disconnect API, so stopping it is
-// the only reliable way to terminate its active local session. The signal host
-// can close relay connections without unregistering itself.
-func (u *ui) disconnectActiveSessions() {
- if u.engine != nil {
- u.engine.Stop()
- }
- u.disconnectSignalSessions()
-}
-
-func (u *ui) handleChatMessage(from, text string) {
- line := from + ": " + text
- u.chatMessages = append(u.chatMessages, line)
-}
-
-func (u *ui) buildMainLayout() fyne.CanvasObject {
- header := u.buildBrandedHeaderBar()
- body := u.buildContent()
- footer := u.buildFooterBar()
- return container.NewBorder(header, footer, nil, nil, body)
-}
-
-func (u *ui) buildContent() fyne.CanvasObject {
- deviceID, mode, password, custom := u.state.Snapshot()
-
- bodyLogo := u.buildBodyLogo()
-
- idBox := u.newInfoBox(t("your_id"), formatDeviceID(deviceID), false, func() {
- u.win.Clipboard().SetContent(deviceID)
- u.notify(t("copied"))
- })
-
- displayPw := maskPassword(password)
- if u.pwShown {
- displayPw = password
- }
- u.pwValueLbl = widget.NewLabelWithStyle(displayPw, fyne.TextAlignLeading, fyne.TextStyle{Bold: true, Monospace: true})
- u.pwBox = u.newInfoBox(t("access_password"), u.pwValueLbl, true, func() {
- _, _, pw, _ := u.state.Snapshot()
- u.win.Clipboard().SetContent(pw)
- u.notify(t("copied"))
- })
- if !u.shouldShowPasswordBox(mode, custom) {
- u.pwBox.Hide()
- }
-
- helpBtn := newSecondaryButton(t("request_help"), theme.MailSendIcon(), u.showHelpDialog)
- chatBtn := newPrimaryButton(t("chat_with_support"), theme.MailComposeIcon(), u.showChatWindow)
-
- u.statusLbl = widget.NewLabelWithStyle(t("status_ready"), fyne.TextAlignLeading, fyne.TextStyle{})
- u.statusDot = canvas.NewRectangle(statusColor(statusKindReady, u.brand))
- u.statusDot.SetMinSize(fyne.NewSize(10, 10))
- u.statusDot.CornerRadius = 5
- dotBox := container.NewCenter(u.statusDot)
- statusRow := container.NewBorder(nil, nil, dotBox, nil, u.statusLbl)
- u.updateStatus()
-
- items := []fyne.CanvasObject{}
- if bodyLogo != nil {
- items = append(items, bodyLogo)
- }
- items = append(items,
- statusRow,
- idBox,
- u.pwBox,
- helpBtn,
- chatBtn,
- )
- return container.NewPadded(container.NewVBox(items...))
-}
-
-func (u *ui) accessModeOptions() []string {
- opts := []string{t("mode_supervised"), t("mode_disabled")}
- if u.brand.AllowUnattended {
- opts = []string{t("mode_supervised"), t("mode_unattended"), t("mode_disabled")}
- }
- return opts
-}
-
-func (u *ui) buildBodyLogo() fyne.CanvasObject {
- if res := u.brand.LogoResource(); res != nil {
- img := canvas.NewImageFromResource(res)
- img.FillMode = canvas.ImageFillContain
- img.SetMinSize(fyne.NewSize(120, 80))
- return container.NewCenter(img)
- }
- return nil
-}
-
-// showHelpDialog prompts for a problem description and sends a help request.
-func (u *ui) showHelpDialog() {
- entry := widget.NewMultiLineEntry()
- entry.SetPlaceHolder(t("help_message"))
- entry.SetMinRowsVisible(3)
-
- form := dialog.NewCustomConfirm(t("request_help"), t("send"), t("cancel"),
- entry, func(ok bool) {
- if !ok {
- return
- }
- go func() {
- err := SendHelpRequest(u.engine, u.brand, u.state, entry.Text)
- if err != nil {
- u.notify(t("help_failed"))
- log.Printf("[support-agent] help request: %v", err)
- return
- }
- u.notify(t("help_sent"))
- }()
- }, u.win)
- form.Resize(fyne.NewSize(420, 240))
- form.Show()
-}
-
-func wrapLabel(text string) *widget.Label {
- lbl := widget.NewLabel(text)
- lbl.Wrapping = fyne.TextWrapWord
- return lbl
-}
-
-func (u *ui) showConnTest() {
- progress := dialog.NewCustom(t("test_connection"), t("close"),
- widget.NewLabelWithStyle(t("test_running"), fyne.TextAlignCenter, fyne.TextStyle{Italic: true}), u.win)
- progress.Show()
-
- go func() {
- // #region agent log
- debugLog("H1", "app.go:showConnTest", "probe urls", map[string]any{
- "cdap_health": u.brand.CDAPHealthURL(),
- "api_health": u.brand.APIHealthURL(),
- "register_url": apiBaseURL(u.brand) + "/devices/register",
- })
- // #endregion
- res := TestConnectionExtended(u.brand, u.state)
- logConnectionTest(res)
- progress.Hide()
- line := func(ok bool, name string, p ProbeResult) string {
- mark := "✕"
- if ok {
- mark = "✓"
- }
- return mark + " " + name + " — " + p.Detail
- }
- content := container.NewVBox(
- wrapLabel(line(res.CDAP.OK, t("test_gateway"), res.CDAP)),
- wrapLabel(line(res.API.OK, t("test_api"), res.API)),
- wrapLabel(line(res.Enrollment.OK, t("test_enrollment"), res.Enrollment)),
- )
- scroll := container.NewScroll(content)
- scroll.SetMinSize(fyne.NewSize(440, 120))
- title := t("test_failed")
- if res.AllOK() {
- title = t("test_ok")
- }
- d := dialog.NewCustom(title, t("close"), scroll, u.win)
- d.Resize(fyne.NewSize(520, 220))
- d.Show()
- }()
-}
-
-func (u *ui) showCustomPasswordDialog() {
- entry := widget.NewPasswordEntry()
- entry.SetPlaceHolder(t("custom_password"))
-
- d := dialog.NewCustomConfirm(t("set_custom"), t("save"), t("cancel"),
- entry, func(ok bool) {
- if !ok {
- return
- }
- if err := u.state.SetCustomPassword(entry.Text); err != nil {
- u.notify(err.Error())
- return
- }
- u.refreshPassword()
- go func() { _ = SyncAccessPassword(u.brand, u.state) }()
- }, u.win)
- d.Show()
-}
-
-func (u *ui) onModeChange(label string) {
- mode := modeFromLabel(label)
- _, cur, _, _ := u.state.Snapshot()
- if mode == cur {
- return
- }
- if !u.brand.AllowUnattended && mode == AccessUnattended {
- return
- }
- if err := u.state.SetAccessMode(mode); err != nil {
- u.notify(err.Error())
- return
- }
- if u.pwBox != nil {
- _, newMode, _, newCustom := u.state.Snapshot()
- if u.shouldShowPasswordBox(newMode, newCustom) {
- u.pwBox.Show()
- } else {
- u.pwBox.Hide()
- }
- }
- // AccessDisabled must immediately remove the signal/relay presence, not
- // merely reject a later login attempt.
- u.stopSignalHost()
- if u.brand.HasConnection() && u.state.IsEnrolled() {
- u.engine.Stop()
- go func() {
- if err := SyncAccessPassword(u.brand, u.state); err != nil {
- log.Printf("[support-agent] access password sync: %v", err)
- }
- if err := u.engine.Restart(u.state); err != nil {
- log.Printf("[support-agent] engine restart: %v", err)
- return
- }
- u.startSignalHost()
- }()
- }
-}
-
-func (u *ui) rebuildMainLayout() {
- u.win.SetContent(u.buildMainLayout())
-}
-
-func (u *ui) refreshPassword() {
- _, mode, pw, custom := u.state.Snapshot()
- display := maskPassword(pw)
- if u.pwShown {
- display = pw
- }
- if u.pwValueLbl != nil {
- u.pwValueLbl.SetText(display)
- }
- if u.pwBox != nil {
- if u.shouldShowPasswordBox(mode, custom) {
- u.pwBox.Show()
- } else {
- u.pwBox.Hide()
- }
- u.pwBox.Refresh()
- }
-}
-
-func (u *ui) setupTray() {
- deskApp, ok := u.app.(desktop.App)
- if !ok {
- return
- }
- menu := fyne.NewMenu(u.brand.ProductName,
- fyne.NewMenuItem(t("window_title"), func() {
- u.win.Show()
- u.win.RequestFocus()
- }),
- fyne.NewMenuItem(t("request_help"), u.showHelpDialog),
- )
- deskApp.SetSystemTrayMenu(menu)
- if res := u.brand.TrayIconResource(); res != nil {
- deskApp.SetSystemTrayIcon(res)
- }
- u.win.SetCloseIntercept(func() { u.win.Hide() })
-}
-
-func (u *ui) notify(msg string) {
- u.app.SendNotification(fyne.NewNotification(u.brand.ProductName, msg))
-}
-
-func modeLabel(mode string) string {
- switch mode {
- case AccessUnattended:
- return t("mode_unattended")
- case AccessDisabled:
- return t("mode_disabled")
- default:
- return t("mode_supervised")
- }
-}
diff --git a/betterdesk-support-agent/app_wails.go b/betterdesk-support-agent/app_wails.go
deleted file mode 100644
index 0547a4dd..00000000
--- a/betterdesk-support-agent/app_wails.go
+++ /dev/null
@@ -1,52 +0,0 @@
-//go:build !fyneui
-
-package main
-
-import (
- "embed"
- "log"
-
- "github.com/wailsapp/wails/v2"
- "github.com/wailsapp/wails/v2/pkg/options"
- "github.com/wailsapp/wails/v2/pkg/options/assetserver"
- "github.com/wailsapp/wails/v2/pkg/options/windows"
-)
-
-//go:embed all:frontend/ui
-var frontendAssets embed.FS
-
-func run() {
- svc, err := newAppService()
- if err != nil {
- log.Fatalf("[support-agent] state: %v", err)
- }
-
- err = wails.Run(&options.App{
- Title: svc.brand.ProductName,
- Width: 420,
- Height: 640,
- MinWidth: 380,
- MinHeight: 560,
- MaxWidth: 520,
- MaxHeight: 820,
- AssetServer: &assetserver.Options{
- Assets: frontendAssets,
- },
- BackgroundColour: &options.RGBA{R: 255, G: 255, B: 255, A: 255},
- OnStartup: svc.startup,
- OnShutdown: svc.shutdown,
- // The agent continues to receive supervised-session requests after its
- // window is closed. Keep it available through the Windows notification
- // area instead of terminating the remote-access engine.
- HideWindowOnClose: true,
- Bind: []interface{}{svc},
- Windows: &windows.Options{
- WebviewIsTransparent: false,
- WindowIsTranslucent: false,
- DisableWindowIcon: false,
- },
- })
- if err != nil {
- log.Fatalf("[support-agent] wails: %v", err)
- }
-}
diff --git a/betterdesk-support-agent/applog.go b/betterdesk-support-agent/applog.go
deleted file mode 100644
index 42e10b4a..00000000
--- a/betterdesk-support-agent/applog.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "fmt"
- "log"
- "os"
- "path/filepath"
- "sync"
- "time"
-)
-
-var (
- appLogMu sync.Mutex
-)
-
-type appLogEntry struct {
- Time string `json:"time"`
- Level string `json:"level"`
- Event string `json:"event"`
- Message string `json:"message,omitempty"`
- Fields map[string]any `json:"fields,omitempty"`
-}
-
-func appLogPath() string {
- return filepath.Join(stateDir(), "support-agent.log")
-}
-
-func writeAppLog(level, event, message string, fields map[string]any) {
- entry := appLogEntry{
- Time: time.Now().UTC().Format(time.RFC3339),
- Level: level,
- Event: event,
- Message: message,
- Fields: fields,
- }
- line, err := json.Marshal(entry)
- if err != nil {
- return
- }
- line = append(line, '\n')
-
- appLogMu.Lock()
- defer appLogMu.Unlock()
-
- path := appLogPath()
- if dir := filepath.Dir(path); dir != "" {
- _ = os.MkdirAll(dir, 0o700)
- }
- f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
- if err != nil {
- return
- }
- // OpenFile's mode only applies on creation. Repair a pre-existing log so
- // release diagnostics containing device/session metadata never remain
- // world-readable after an older build or manual file replacement.
- if err := f.Chmod(0o600); err != nil {
- _ = f.Close()
- return
- }
- _, _ = f.Write(line)
- _ = f.Close()
-}
-
-func appLogInfo(event, message string, fields map[string]any) {
- writeAppLog("info", event, message, fields)
- log.Printf("[support-agent] %s: %s", event, message)
-}
-
-func appLogWarn(event, message string, fields map[string]any) {
- writeAppLog("warn", event, message, fields)
- log.Printf("[support-agent] %s: %s", event, message)
-}
-
-func appLogError(event, message string, fields map[string]any) {
- writeAppLog("error", event, message, fields)
- log.Printf("[support-agent] %s: %s", event, message)
-}
-
-func logConnectionTest(res ExtendedConnCheck) {
- appLogInfo("connection_test", fmt.Sprintf("cdap=%v api=%v enroll=%v",
- res.CDAP.OK, res.API.OK, res.Enrollment.OK), map[string]any{
- "cdap_detail": res.CDAP.Detail,
- "api_detail": res.API.Detail,
- "enrollment_detail": res.Enrollment.Detail,
- })
-}
diff --git a/betterdesk-support-agent/applog_test.go b/betterdesk-support-agent/applog_test.go
deleted file mode 100644
index 9a4fedc4..00000000
--- a/betterdesk-support-agent/applog_test.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package main
-
-import (
- "os"
- "path/filepath"
- "runtime"
- "testing"
-)
-
-func TestWriteAppLogRepairsExistingFilePermissions(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("Windows does not report POSIX file permissions")
- }
-
- dir := t.TempDir()
- t.Setenv("BETTERDESK_AGENT_DATA_DIR", dir)
- path := filepath.Join(dir, "support-agent.log")
- if err := os.WriteFile(path, []byte("old entry\n"), 0o644); err != nil {
- t.Fatalf("seed log: %v", err)
- }
- if err := os.Chmod(path, 0o644); err != nil {
- t.Fatalf("make log permissive: %v", err)
- }
-
- writeAppLog("info", "test", "test log", nil)
-
- info, err := os.Stat(path)
- if err != nil {
- t.Fatalf("stat log: %v", err)
- }
- if got := info.Mode().Perm(); got != 0o600 {
- t.Fatalf("log permissions = %o, want 0600", got)
- }
-}
diff --git a/betterdesk-support-agent/appservice.go b/betterdesk-support-agent/appservice.go
deleted file mode 100644
index 5ba297c8..00000000
--- a/betterdesk-support-agent/appservice.go
+++ /dev/null
@@ -1,585 +0,0 @@
-package main
-
-import (
- "context"
- "log"
- "sync"
- "time"
-
- "github.com/unitronix/betterdesk-support-agent/signalhost"
- "github.com/wailsapp/wails/v2/pkg/runtime"
-)
-
-// AppService is the GUI-agnostic core bound to Wails (and embedded by Fyne UI).
-type AppService struct {
- brand Branding
- state *AppState
- engine *Engine
-
- signalHostMu sync.Mutex
- signalHost *signalhost.Host
-
- consentMu sync.Mutex
- pendingConsent *consentRequest
- chatMessages []string
- chatMu sync.Mutex
-
- statusMu sync.Mutex
- statusKind statusKind
- statusText string
-
- sessionMu sync.Mutex
- sessionActive bool
- sessionOp string
- sessionMode string
-
- ctx context.Context
- trayOnce sync.Once
-}
-
-type UISnapshot struct {
- ProductName string `json:"product_name"`
- CompanyName string `json:"company_name"`
- Tagline string `json:"tagline"`
- PrimaryColor string `json:"primary_color"`
- AccentColor string `json:"accent_color"`
- SurfaceColor string `json:"surface_color"`
- BackgroundColor string `json:"background_color"`
- TextColor string `json:"text_color"`
- TextMutedColor string `json:"text_muted_color"`
- StatusReadyColor string `json:"status_ready_color"`
- HeaderTextColor string `json:"header_text_color"`
- LogoDataURL string `json:"logo_data_url"`
- SupportEmail string `json:"support_email"`
- SupportPhone string `json:"support_phone"`
- ContactURL string `json:"contact_url"`
- Version string `json:"version"`
- DeviceID string `json:"device_id"`
- DeviceIDFmt string `json:"device_id_fmt"`
- Password string `json:"password"`
- PasswordMasked string `json:"password_masked"`
- AccessMode string `json:"access_mode"`
- CustomPassword bool `json:"custom_password"`
- ShowPassword bool `json:"show_password"`
- AllowUnattended bool `json:"allow_unattended"`
- Language string `json:"language"`
- StatusKind string `json:"status_kind"`
- StatusText string `json:"status_text"`
- SessionActive bool `json:"session_active"`
- SessionOperator string `json:"session_operator"`
- SessionMode string `json:"session_mode"`
- ModeOptions []string `json:"mode_options"`
- Strings map[string]string `json:"strings"`
-}
-
-type ConnTestDTO struct {
- OK bool `json:"ok"`
- Title string `json:"title"`
- Gateway string `json:"gateway"`
- API string `json:"api"`
- Enrollment string `json:"enrollment"`
-}
-
-func newAppService() (*AppService, error) {
- brand := GetBranding()
- st, err := LoadState()
- if err != nil {
- return nil, err
- }
- setLang(st.Language)
- s := &AppService{
- brand: brand,
- state: st,
- engine: NewEngine(version),
- }
- s.engine.SetCallbacks(s.handleConsent, s.handleSessionStart, s.handleSessionEnd)
- s.engine.SetChatHandler(s.handleChatMessage)
- s.applyStatus(statusKindReady, t("status_ready"))
- return s, nil
-}
-
-func (s *AppService) startup(ctx context.Context) {
- s.ctx = ctx
- s.startTray()
- if s.brand.HasConnection() {
- s.bootstrapConnection()
- }
- log.Printf("[support-agent] %s starting (device=%s) ui=wails", version, s.state.DeviceID)
- appLogInfo("startup", "support agent started", map[string]any{"device_id": s.state.DeviceID, "version": version, "ui": "wails"})
-}
-
-func (s *AppService) shutdown(ctx context.Context) {
- s.stopTray()
- s.stopRemoteAccessForEnrollmentState()
-}
-
-func (s *AppService) emit(event string, data any) {
- if s.ctx == nil {
- return
- }
- runtime.EventsEmit(s.ctx, event, data)
-}
-
-func (s *AppService) GetSnapshot() UISnapshot {
- deviceID, mode, password, custom := s.state.Snapshot()
- s.statusMu.Lock()
- kind, text := s.statusKind, s.statusText
- s.statusMu.Unlock()
- s.sessionMu.Lock()
- active, op, smode := s.sessionActive, s.sessionOp, s.sessionMode
- s.sessionMu.Unlock()
-
- keys := []string{
- "window_title", "your_id", "access_password", "request_help", "chat_with_support",
- "settings", "quit", "copied", "mode_supervised", "mode_unattended", "mode_disabled",
- "language", "test_connection", "set_custom", "regenerate", "totp", "send", "cancel",
- "save", "close", "consent_title", "consent_accept", "consent_deny", "consent_prompt",
- "consent_ack", "consent_display_name", "consent_session",
- "help_message", "help_sent", "help_failed", "session_active", "session_with",
- "disconnect", "close_session", "ongoing_session", "receive_support", "receive_support_hint",
- "or_share_id", "share_id_password", "share_id_hint", "password_regenerated", "chat_placeholder",
- "connected", "disconnected", "status_ready", "enrollment_pending", "enrollment_rejected",
- }
- strs := make(map[string]string, len(keys))
- for _, k := range keys {
- strs[k] = t(k)
- }
-
- return UISnapshot{
- ProductName: s.brand.ProductName,
- CompanyName: s.brand.CompanyName,
- Tagline: s.brand.Tagline,
- PrimaryColor: s.brand.PrimaryColor,
- AccentColor: s.brand.AccentColor,
- SurfaceColor: s.brand.SurfaceColor,
- BackgroundColor: s.brand.BackgroundColor,
- TextColor: s.brand.TextColor,
- TextMutedColor: s.brand.TextMutedColor,
- StatusReadyColor: s.brand.StatusReadyColor,
- HeaderTextColor: s.brand.HeaderTextColor,
- LogoDataURL: s.brand.LogoDataURL,
- SupportEmail: s.brand.SupportEmail,
- SupportPhone: s.brand.SupportPhone,
- ContactURL: s.brand.ContactURL,
- Version: version,
- DeviceID: deviceID,
- DeviceIDFmt: formatDeviceID(deviceID),
- Password: password,
- PasswordMasked: maskPassword(password),
- AccessMode: mode,
- CustomPassword: custom,
- ShowPassword: s.shouldShowPasswordBox(mode, custom),
- AllowUnattended: s.brand.AllowUnattended,
- Language: s.state.Language,
- StatusKind: statusKindName(kind),
- StatusText: text,
- SessionActive: active,
- SessionOperator: op,
- SessionMode: smode,
- ModeOptions: s.accessModeOptions(),
- Strings: strs,
- }
-}
-
-func (s *AppService) accessModeOptions() []string {
- if s.brand.AllowUnattended {
- return []string{t("mode_supervised"), t("mode_unattended"), t("mode_disabled")}
- }
- return []string{t("mode_supervised"), t("mode_disabled")}
-}
-
-func (s *AppService) shouldShowPasswordBox(mode string, custom bool) bool {
- return mode != AccessDisabled
-}
-
-func (s *AppService) SetAccessMode(label string) error {
- mode := modeFromLabel(label)
- _, cur, _, _ := s.state.Snapshot()
- if mode == cur {
- return nil
- }
- if !s.brand.AllowUnattended && mode == AccessUnattended {
- return nil
- }
- if err := s.state.SetAccessMode(mode); err != nil {
- return err
- }
- s.stopSignalHost()
- if s.brand.HasConnection() && s.state.IsEnrolled() {
- s.engine.Stop()
- go func() {
- if err := SyncAccessPassword(s.brand, s.state); err != nil {
- log.Printf("[support-agent] access password sync: %v", err)
- }
- if err := s.engine.Restart(s.state); err != nil {
- log.Printf("[support-agent] engine restart: %v", err)
- return
- }
- s.startSignalHost()
- s.emit("snapshot", s.GetSnapshot())
- }()
- }
- s.emit("snapshot", s.GetSnapshot())
- return nil
-}
-
-func (s *AppService) SetLanguage(lang string) error {
- if err := s.state.SetLanguage(lang); err != nil {
- return err
- }
- setLang(lang)
- s.emit("snapshot", s.GetSnapshot())
- return nil
-}
-
-func (s *AppService) SetCustomPassword(pw string) error {
- if err := s.state.SetCustomPassword(pw); err != nil {
- return err
- }
- go func() { _ = SyncAccessPassword(s.brand, s.state) }()
- s.emit("snapshot", s.GetSnapshot())
- return nil
-}
-
-func (s *AppService) RegeneratePassword() error {
- if err := s.state.RegeneratePassword(); err != nil {
- return err
- }
- go func() { _ = SyncAccessPassword(s.brand, s.state) }()
- s.emit("snapshot", s.GetSnapshot())
- return nil
-}
-
-func (s *AppService) SendHelp(message string) error {
- err := SendHelpRequest(s.engine, s.brand, s.state, message)
- if err != nil {
- return err
- }
- s.emit("toast", t("help_sent"))
- return nil
-}
-
-func (s *AppService) SendChat(text string) error {
- if text == "" {
- return nil
- }
- s.chatMu.Lock()
- s.chatMessages = append(s.chatMessages, "me: "+text)
- s.chatMu.Unlock()
- if err := SendChatMessage(s.engine, text); err != nil {
- return err
- }
- s.emit("chat", s.GetChatHistory())
- return nil
-}
-
-func (s *AppService) GetChatHistory() []string {
- s.chatMu.Lock()
- defer s.chatMu.Unlock()
- out := make([]string, len(s.chatMessages))
- copy(out, s.chatMessages)
- return out
-}
-
-func (s *AppService) TestConnection() ConnTestDTO {
- res := TestConnectionExtended(s.brand, s.state)
- logConnectionTest(res)
- line := func(ok bool, name string, p ProbeResult) string {
- mark := "✕"
- if ok {
- mark = "✓"
- }
- return mark + " " + name + " — " + p.Detail
- }
- title := t("test_failed")
- if res.AllOK() {
- title = t("test_ok")
- }
- return ConnTestDTO{
- OK: res.AllOK(),
- Title: title,
- Gateway: line(res.CDAP.OK, t("test_gateway"), res.CDAP),
- API: line(res.API.OK, t("test_api"), res.API),
- Enrollment: line(res.Enrollment.OK, t("test_enrollment"), res.Enrollment),
- }
-}
-
-func (s *AppService) AnswerConsent(granted bool) {
- s.consentMu.Lock()
- req := s.pendingConsent
- s.pendingConsent = nil
- s.consentMu.Unlock()
- if req != nil {
- select {
- case req.response <- granted:
- default:
- }
- }
-}
-
-func (s *AppService) DisconnectSession() {
- if s.engine != nil {
- s.engine.Stop()
- }
- s.disconnectSignalSessions()
- s.handleSessionEnd("")
-}
-
-func (s *AppService) Quit() {
- if s.ctx != nil {
- runtime.Quit(s.ctx)
- }
-}
-
-func (s *AppService) applyStatus(kind statusKind, text string) {
- text = shortenStatusText(text, 160)
- s.statusMu.Lock()
- s.statusKind = kind
- s.statusText = text
- s.statusMu.Unlock()
- s.emit("status", map[string]string{"kind": statusKindName(kind), "text": text})
- s.emit("snapshot", s.GetSnapshot())
-}
-
-func statusKindName(kind statusKind) string {
- switch kind {
- case statusKindConnected:
- return "connected"
- case statusKindPending:
- return "pending"
- case statusKindError:
- return "error"
- default:
- return "ready"
- }
-}
-
-func (s *AppService) bootstrapConnection() {
- go func() {
- res, err := EnsureEnrolled(s.brand, s.state, version)
- if err != nil {
- log.Printf("[support-agent] enrollment: %v", err)
- s.applyStatus(statusKindError, t("enrollment_error")+" — "+shortenErr(err.Error()))
- return
- }
- s.onEnrollmentUpdate(res)
- if res.Status == EnrollmentPending {
- StartEnrollmentPoll(s.brand, s.state, version, 5*time.Second, s.onEnrollmentUpdate)
- }
- s.startStatusLoop()
- }()
-}
-
-func (s *AppService) onEnrollmentUpdate(res EnrollmentStatus) {
- switch res.Status {
- case EnrollmentApproved:
- if !s.state.IsEnrolled() {
- s.applyStatus(statusKindPending, t("enrollment_pending"))
- return
- }
- if s.engine.Running() {
- s.applyStatus(statusKindConnected, t("connected"))
- } else {
- s.applyStatus(statusKindPending, t("disconnected"))
- }
- if err := SyncAccessPassword(s.brand, s.state); err != nil {
- log.Printf("[support-agent] access password sync: %v", err)
- }
- if !s.engine.Running() {
- if err := s.engine.Start(s.state); err != nil {
- log.Printf("[support-agent] engine start: %v", err)
- return
- }
- }
- if s.engine.Running() {
- s.startSignalHost()
- }
- case EnrollmentPending:
- s.stopRemoteAccessForEnrollmentState()
- msg := t("enrollment_pending")
- if res.Message != "" {
- msg = res.Message
- }
- s.applyStatus(statusKindPending, msg)
- case EnrollmentRejected:
- s.stopRemoteAccessForEnrollmentState()
- s.applyStatus(statusKindError, t("enrollment_rejected"))
- default:
- s.applyStatus(statusKindPending, t("disconnected"))
- }
-}
-
-func (s *AppService) stopRemoteAccessForEnrollmentState() {
- if s.engine != nil {
- s.engine.Stop()
- }
- s.stopSignalHost()
-}
-
-func (s *AppService) handleConsent(sessionID, operator string) bool {
- resp := make(chan bool, 1)
- req := &consentRequest{sessionID: sessionID, operator: operator, response: resp}
- s.consentMu.Lock()
- if s.pendingConsent != nil {
- s.consentMu.Unlock()
- return false
- }
- s.pendingConsent = req
- s.consentMu.Unlock()
- s.emit("consent", map[string]string{
- "session_id": sessionID,
- "operator": operator,
- "prompt": t("consent_prompt") + " " + operator,
- })
- select {
- case granted := <-resp:
- appLogInfo("consent", "remote access consent answered", map[string]any{
- "session_id": sessionID, "operator": operator, "granted": granted,
- })
- return granted
- case <-time.After(30 * time.Second):
- s.consentMu.Lock()
- if s.pendingConsent == req {
- s.pendingConsent = nil
- }
- s.consentMu.Unlock()
- return false
- }
-}
-
-func (s *AppService) handleSessionStart(sessionID, operator, mode string) {
- s.sessionMu.Lock()
- s.sessionActive = true
- s.sessionOp = operator
- s.sessionMode = mode
- s.sessionMu.Unlock()
- s.emit("session", map[string]any{"active": true, "operator": operator, "mode": mode})
- s.emit("snapshot", s.GetSnapshot())
-}
-
-func (s *AppService) handleSessionEnd(sessionID string) {
- s.sessionMu.Lock()
- s.sessionActive = false
- s.sessionOp = ""
- s.sessionMode = ""
- s.sessionMu.Unlock()
- s.emit("session", map[string]any{"active": false})
- s.emit("snapshot", s.GetSnapshot())
-}
-
-func (s *AppService) handleChatMessage(from, text string) {
- s.chatMu.Lock()
- s.chatMessages = append(s.chatMessages, from+": "+text)
- s.chatMu.Unlock()
- s.emit("chat", s.GetChatHistory())
-}
-
-func (s *AppService) startStatusLoop() {
- go func() {
- ticker := time.NewTicker(3 * time.Second)
- defer ticker.Stop()
- var lastEnrollmentCheck time.Time
- for range ticker.C {
- s.updateStatus()
- if !lastEnrollmentCheck.IsZero() &&
- time.Since(lastEnrollmentCheck) < enrollmentRevalidationInterval {
- continue
- }
- status, _, _ := s.state.EnrollmentSnapshot()
- if status != EnrollmentApproved || !s.brand.HasConnection() {
- continue
- }
- lastEnrollmentCheck = time.Now()
- go s.revalidateEnrollment()
- }
- }()
-}
-
-func (s *AppService) revalidateEnrollment() {
- result, err := PollEnrollment(s.brand, s.state, version)
- if err != nil {
- return
- }
- if result.Status != EnrollmentApproved {
- s.onEnrollmentUpdate(result)
- }
-}
-
-func (s *AppService) updateStatus() {
- if !s.brand.HasConnection() {
- s.applyStatus(statusKindReady, t("status_ready"))
- return
- }
- status, _, _ := s.state.EnrollmentSnapshot()
- switch status {
- case EnrollmentPending:
- s.applyStatus(statusKindPending, t("enrollment_pending"))
- return
- case EnrollmentRejected:
- s.applyStatus(statusKindError, t("enrollment_rejected"))
- return
- case EnrollmentApproved:
- if s.state.IsEnrolled() && s.engine.Running() {
- s.applyStatus(statusKindConnected, t("connected"))
- } else if s.state.IsEnrolled() {
- s.applyStatus(statusKindPending, t("disconnected"))
- } else {
- s.applyStatus(statusKindPending, t("enrollment_pending"))
- }
- return
- }
- if s.engine.Running() {
- s.applyStatus(statusKindConnected, t("connected"))
- } else {
- s.applyStatus(statusKindReady, t("status_ready"))
- }
-}
-
-func (s *AppService) startSignalHost() {
- s.signalHostMu.Lock()
- defer s.signalHostMu.Unlock()
- if s.signalHost != nil {
- return
- }
- host, _ := newSignalHost(s.brand, s.state, false, signalHostCallbacks{
- consent: func(operator string) bool {
- return s.handleConsent("signal", operator)
- },
- audit: func(policy hostCapabilityPolicy) {
- auditHostCapabilityPolicy(hostCapabilityAuditTransportSignal, policy)
- },
- onSession: func(start bool, operator string) {
- if start {
- s.handleSessionStart("signal", operator, "signal")
- } else {
- s.handleSessionEnd("signal")
- }
- },
- })
- if host == nil {
- return
- }
- if !host.Start() {
- return
- }
- s.signalHost = host
-}
-
-func (s *AppService) stopSignalHost() {
- s.signalHostMu.Lock()
- defer s.signalHostMu.Unlock()
- if s.signalHost == nil {
- return
- }
- s.signalHost.Stop()
- s.signalHost = nil
-}
-
-func (s *AppService) disconnectSignalSessions() {
- s.signalHostMu.Lock()
- host := s.signalHost
- s.signalHostMu.Unlock()
- if host != nil {
- host.DisconnectSessions()
- }
-}
diff --git a/betterdesk-support-agent/appservice_test.go b/betterdesk-support-agent/appservice_test.go
deleted file mode 100644
index 52d5434d..00000000
--- a/betterdesk-support-agent/appservice_test.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package main
-
-import "testing"
-
-func TestUISnapshotIncludesBrandContactDetails(t *testing.T) {
- t.Setenv("BETTERDESK_AGENT_DATA_DIR", t.TempDir())
- st, err := LoadState()
- if err != nil {
- t.Fatal(err)
- }
- svc := &AppService{
- brand: Branding{
- ProductName: "Acme Support",
- SupportEmail: "support@example.test",
- SupportPhone: "+48 123 456 789",
- ContactURL: "https://example.test/help",
- },
- state: st,
- statusKind: statusKindReady,
- statusText: "Ready",
- }
-
- snapshot := svc.GetSnapshot()
- if snapshot.SupportEmail != "support@example.test" ||
- snapshot.SupportPhone != "+48 123 456 789" ||
- snapshot.ContactURL != "https://example.test/help" {
- t.Fatalf("contact fields missing from UI snapshot: %#v", snapshot)
- }
-}
diff --git a/betterdesk-support-agent/branding.go b/betterdesk-support-agent/branding.go
deleted file mode 100644
index 02a56a7e..00000000
--- a/betterdesk-support-agent/branding.go
+++ /dev/null
@@ -1,358 +0,0 @@
-package main
-
-import (
- _ "embed"
- "encoding/base64"
- "encoding/json"
- "fmt"
- "os"
- "strings"
- "sync"
- "time"
-
- "github.com/unitronix/betterdesk-support-agent/internal/brandprofile"
-)
-
-//go:embed resources/branding.json
-var brandingJSON []byte
-
-//go:embed resources/branding.pub
-var brandingPublicKey []byte
-
-type ServerBranding struct {
- Address string `json:"address"`
- APIURL string `json:"api_url"`
- PublicKey string `json:"public_key"`
- CertPin string `json:"cert_pin,omitempty"`
- CDAPPort int `json:"cdap_port,omitempty"`
- ConsolePort int `json:"console_port,omitempty"`
- CDAPURL string `json:"cdap_url,omitempty"`
- ConsoleURL string `json:"console_url,omitempty"`
-}
-
-type Branding struct {
- ProductName string `json:"product_name"`
- CompanyName string `json:"company_name"`
- Tagline string `json:"tagline"`
- TaglineAlt string `json:"short_text"`
- SupportEmail string `json:"support_email"`
- SupportEmailAlt string `json:"contact_email"`
- SupportPhone string `json:"support_phone"`
- SupportPhoneAlt string `json:"contact_phone"`
- ContactURL string `json:"contact_url"`
- PrimaryColor string `json:"primary_color"`
- AccentColor string `json:"accent_color"`
- BackgroundColor string `json:"background_color"`
- SurfaceColor string `json:"surface_color"`
- TextColor string `json:"text_color"`
- TextMutedColor string `json:"text_muted_color"`
- StatusReadyColor string `json:"status_ready_color"`
- HeaderTextColor string `json:"header_text_color"`
- LogoDataURL string `json:"logo_data_url"`
- DefaultLanguage string `json:"default_language"`
- DefaultLangAlt string `json:"default_lang"`
- AllowUnattended bool `json:"allow_unattended"`
- ServerAddress string `json:"server_address"`
- ServerKey string `json:"server_key"`
- APIKey string `json:"api_key"`
- BundleID string `json:"bundle_id"`
- ProfileIssuedAt string `json:"profile_issued_at,omitempty"`
- ProfileExpiresAt string `json:"profile_expires_at,omitempty"`
- AllowedEndpoints []string `json:"allowed_endpoints,omitempty"`
- UseHTTPS bool `json:"use_https"`
- Server *ServerBranding `json:"server,omitempty"`
- // Incoming host capability policy. Desktop remains enabled by default for
- // existing bundles; all other capabilities require both a profile opt-in
- // and a locally enforceable implementation before they can be exposed.
- Capabilities *CapabilityFlags `json:"capabilities,omitempty"`
-}
-
-// CapabilityFlags gates incoming session features for Support Agent builds.
-type CapabilityFlags struct {
- Desktop *bool `json:"desktop,omitempty"`
- Files *bool `json:"files,omitempty"`
- Clipboard *bool `json:"clipboard,omitempty"`
- Audio *bool `json:"audio,omitempty"`
- Terminal *bool `json:"terminal,omitempty"`
- Chat *bool `json:"chat,omitempty"`
- MultiMonitor *bool `json:"multi_monitor,omitempty"`
- PrivacyMode *bool `json:"privacy_mode,omitempty"`
- BlockInput *bool `json:"block_input,omitempty"`
- Restart *bool `json:"restart,omitempty"`
- Recording *bool `json:"recording,omitempty"`
-}
-
-func capEnabled(flag *bool, defaultOn bool) bool {
- if flag == nil {
- return defaultOn
- }
- return *flag
-}
-
-var (
- brandingOnce sync.Once
- brandingVal Branding
-)
-
-func brandingDefaults() Branding {
- return Branding{
- ProductName: "BetterDesk Support",
- CompanyName: "BetterDesk",
- Tagline: "Quick remote help",
- PrimaryColor: "#2563eb",
- AccentColor: "#e0f2fe",
- BackgroundColor: "#ffffff",
- SurfaceColor: "#f3f4f6",
- TextColor: "#1f2937",
- TextMutedColor: "#6b7280",
- StatusReadyColor: "#22c55e",
- HeaderTextColor: "#1f2937",
- DefaultLanguage: "en",
- }
-}
-
-func GetBranding() Branding {
- brandingOnce.Do(func() {
- raw := brandingJSON
- if !isReleaseBuild() {
- if p := os.Getenv("BETTERDESK_AGENT_BRANDING"); p != "" {
- if data, err := os.ReadFile(p); err == nil {
- raw = data
- }
- }
- }
- b, err := decodeBrandingProfile(raw, brandingPublicKey, isReleaseBuild())
- if err != nil {
- // Do not substitute a default endpoint after integrity verification
- // fails. A release binary must be signed by its bundle issuer.
- brandingVal = rejectedBranding()
- return
- }
- brandingVal = b.normalize()
- })
- return brandingVal
-}
-
-func rejectedBranding() Branding {
- b := brandingDefaults()
- b.ServerAddress = ""
- return b
-}
-
-// decodeBrandingProfile validates the profile that is embedded in a binary.
-// Release builds accept only authenticated profiles. Development builds may
-// deliberately load a plaintext or legacy-sealed local profile to preserve the
-// supported developer workflow, but never silently downgrade a signed profile.
-func decodeBrandingProfile(raw, publicKeyResource []byte, requireSigned bool) (Branding, error) {
- if brandprofile.IsSigned(raw) {
- publicKey, err := brandprofile.DecodePublicKey(strings.TrimSpace(string(publicKeyResource)))
- if err != nil {
- return Branding{}, fmt.Errorf("decode branding public key: %w", err)
- }
- plain, err := brandprofile.Verify(raw, publicKey)
- if err != nil {
- return Branding{}, fmt.Errorf("verify branding profile: %w", err)
- }
- raw = plain
- } else {
- if requireSigned {
- return Branding{}, fmt.Errorf("unsigned branding profile in release build")
- }
- if isSealedBranding(raw) {
- plain, err := unsealBranding(raw)
- if err != nil {
- return Branding{}, fmt.Errorf("unseal branding profile: %w", err)
- }
- raw = plain
- }
- }
-
- var branding Branding
- if err := json.Unmarshal(raw, &branding); err != nil {
- return Branding{}, fmt.Errorf("decode branding profile: %w", err)
- }
- if requireSigned {
- if err := branding.validateReleaseProfile(time.Now()); err != nil {
- return Branding{}, err
- }
- }
- return branding, nil
-}
-
-func (b Branding) validateReleaseProfile(now time.Time) error {
- if strings.TrimSpace(b.BundleID) == "" {
- return fmt.Errorf("release branding profile is missing bundle ID")
- }
- if b.Server == nil || strings.TrimSpace(b.Server.Address) == "" ||
- strings.TrimSpace(b.Server.APIURL) == "" || strings.TrimSpace(b.Server.CDAPURL) == "" {
- return fmt.Errorf("release branding profile has incomplete server endpoints")
- }
- if strings.TrimSpace(b.ServerKey) == "" && strings.TrimSpace(b.Server.PublicKey) == "" {
- return fmt.Errorf("release branding profile is missing server signing key")
- }
- if strings.TrimSpace(b.ProfileExpiresAt) == "" {
- return fmt.Errorf("release branding profile is missing expiry")
- }
- if strings.TrimSpace(b.ProfileIssuedAt) == "" {
- return fmt.Errorf("release branding profile is missing issue time")
- }
- issuedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(b.ProfileIssuedAt))
- if err != nil {
- return fmt.Errorf("parse release branding profile issue time: %w", err)
- }
- expiresAt, err := time.Parse(time.RFC3339, strings.TrimSpace(b.ProfileExpiresAt))
- if err != nil {
- return fmt.Errorf("parse release branding profile expiry: %w", err)
- }
- if issuedAt.After(now.UTC().Add(5*time.Minute)) || !expiresAt.After(issuedAt) {
- return fmt.Errorf("release branding profile has invalid validity period")
- }
- if !expiresAt.After(now.UTC()) {
- return fmt.Errorf("release branding profile has expired")
- }
- if !allEndpointsAllowed(b.AllowedEndpoints, b.Server.Address, b.Server.APIURL, b.Server.CDAPURL) {
- return fmt.Errorf("release branding profile has unauthorized endpoint")
- }
- if b.Server.CertPin != "" && normalizeServerCertPin(b.Server.CertPin) == "" {
- return fmt.Errorf("release branding profile has invalid certificate pin")
- }
- return nil
-}
-
-func isAllowedTransportEndpoint(endpoint string) bool {
- lower := strings.ToLower(strings.TrimSpace(endpoint))
- return strings.HasPrefix(lower, "https://") ||
- strings.HasPrefix(lower, "http://") ||
- strings.HasPrefix(lower, "wss://") ||
- strings.HasPrefix(lower, "ws://")
-}
-
-// allEndpointsAllowed requires every baked endpoint to appear in the signed
-// allowlist. HTTPS/WSS and HTTP/WS are both accepted (LAN / RustDesk-style).
-func allEndpointsAllowed(allowed []string, endpoints ...string) bool {
- if len(allowed) == 0 {
- return false
- }
- allowedSet := make(map[string]struct{}, len(allowed))
- for _, endpoint := range allowed {
- endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
- if isAllowedTransportEndpoint(endpoint) {
- allowedSet[endpoint] = struct{}{}
- }
- }
- for _, endpoint := range endpoints {
- endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
- if _, ok := allowedSet[endpoint]; !ok {
- return false
- }
- }
- return true
-}
-
-func isReleaseBuild() bool {
- return releaseBuild
-}
-
-func (b Branding) normalize() Branding {
- if b.Tagline == "" {
- b.Tagline = b.TaglineAlt
- }
- if b.SupportEmail == "" {
- b.SupportEmail = b.SupportEmailAlt
- }
- if b.SupportPhone == "" {
- b.SupportPhone = b.SupportPhoneAlt
- }
- if b.DefaultLanguage == "" {
- b.DefaultLanguage = b.DefaultLangAlt
- }
- if b.Server != nil {
- if b.ServerAddress == "" {
- b.ServerAddress = b.Server.Address
- }
- if b.ServerKey == "" {
- b.ServerKey = b.Server.PublicKey
- }
- if !b.UseHTTPS {
- for _, u := range []string{b.Server.Address, b.Server.ConsoleURL, b.Server.APIURL, b.Server.CDAPURL} {
- if strings.HasPrefix(strings.TrimSpace(u), "https://") || strings.HasPrefix(strings.TrimSpace(u), "wss://") {
- b.UseHTTPS = true
- break
- }
- }
- }
- }
-
- d := brandingDefaults()
- if b.ProductName == "" {
- b.ProductName = d.ProductName
- }
- if b.CompanyName == "" {
- b.CompanyName = d.CompanyName
- }
- if b.PrimaryColor == "" {
- b.PrimaryColor = d.PrimaryColor
- }
- if b.AccentColor == "" {
- b.AccentColor = d.AccentColor
- }
- if b.BackgroundColor == "" {
- b.BackgroundColor = d.BackgroundColor
- }
- if b.SurfaceColor == "" {
- b.SurfaceColor = d.SurfaceColor
- }
- if b.TextColor == "" {
- b.TextColor = d.TextColor
- }
- if b.TextMutedColor == "" {
- b.TextMutedColor = d.TextMutedColor
- }
- if b.StatusReadyColor == "" {
- b.StatusReadyColor = d.StatusReadyColor
- }
- if b.HeaderTextColor == "" {
- b.HeaderTextColor = d.HeaderTextColor
- }
- if b.DefaultLanguage == "" {
- b.DefaultLanguage = d.DefaultLanguage
- }
- return b
-}
-
-// brandingEmbedHasLegacyToken reports whether the baked branding.json still
-// carries a non-empty enrollment_token (shared bundle key — must not be used).
-func brandingEmbedHasLegacyToken() bool {
- var probe map[string]json.RawMessage
- if err := json.Unmarshal(brandingJSON, &probe); err != nil {
- return false
- }
- raw, ok := probe["enrollment_token"]
- if !ok {
- return false
- }
- return strings.Trim(string(raw), `" \t\n\r`) != ""
-}
-
-func (b Branding) LogoBytes() []byte {
- if b.LogoDataURL == "" {
- return nil
- }
- idx := strings.Index(b.LogoDataURL, ",")
- if idx < 0 {
- return nil
- }
- meta, payload := b.LogoDataURL[:idx], b.LogoDataURL[idx+1:]
- if strings.Contains(meta, "base64") {
- data, err := base64.StdEncoding.DecodeString(payload)
- if err != nil {
- return nil
- }
- return data
- }
- return []byte(payload)
-}
-
-func (b Branding) HasConnection() bool {
- return strings.TrimSpace(b.ServerAddress) != ""
-}
diff --git a/betterdesk-support-agent/branding_logo.go b/betterdesk-support-agent/branding_logo.go
deleted file mode 100644
index 9b2ade72..00000000
--- a/betterdesk-support-agent/branding_logo.go
+++ /dev/null
@@ -1,68 +0,0 @@
-//go:build fyneui
-
-package main
-
-import (
- "bytes"
- "image"
- _ "image/gif"
- _ "image/jpeg"
- "image/png"
- "runtime"
-
- "fyne.io/fyne/v2"
- "github.com/fyne-io/image/ico"
-)
-
-// LogoPNGBytes returns branding logo bytes encoded as PNG for Fyne widgets and
-// the system tray. SVG/WebP and other formats are skipped (Fyne cannot load them
-// as raw StaticResource payloads).
-func (b Branding) LogoPNGBytes() []byte {
- raw := b.LogoBytes()
- if len(raw) == 0 {
- return nil
- }
- img, _, err := image.Decode(bytes.NewReader(raw))
- if err != nil {
- return nil
- }
- var buf bytes.Buffer
- if err := png.Encode(&buf, img); err != nil {
- return nil
- }
- return buf.Bytes()
-}
-
-func (b Branding) LogoResource() fyne.Resource {
- if data := b.LogoPNGBytes(); isPNG(data) {
- return fyne.NewStaticResource("logo.png", data)
- }
- return nil
-}
-
-// TrayIconResource returns a platform-appropriate tray icon (ICO on Windows).
-func (b Branding) TrayIconResource() fyne.Resource {
- raw := b.LogoBytes()
- if len(raw) == 0 {
- return nil
- }
- img, _, err := image.Decode(bytes.NewReader(raw))
- if err != nil {
- return nil
- }
- var buf bytes.Buffer
- if runtime.GOOS == "windows" {
- if err := ico.Encode(&buf, img); err != nil {
- return nil
- }
- return fyne.NewStaticResource("logo.ico", buf.Bytes())
- }
- if err := png.Encode(&buf, img); err != nil {
- return nil
- }
- return fyne.NewStaticResource("logo.png", buf.Bytes())
-}
-
-func isPNG(data []byte) bool {
- return len(data) >= 8 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G'
-}
diff --git a/betterdesk-support-agent/branding_logo_test.go b/betterdesk-support-agent/branding_logo_test.go
deleted file mode 100644
index 1651607a..00000000
--- a/betterdesk-support-agent/branding_logo_test.go
+++ /dev/null
@@ -1,29 +0,0 @@
-//go:build fyneui
-
-package main
-
-import (
- "encoding/base64"
- "testing"
-)
-
-func TestLogoPNGBytesSkipsSVG(t *testing.T) {
- svg := "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(` `))
- b := Branding{LogoDataURL: svg}.normalize()
- if b.LogoPNGBytes() != nil {
- t.Fatal("expected SVG logo to be skipped for Fyne")
- }
-}
-
-func TestLogoPNGBytesAcceptsPNG(t *testing.T) {
- // 1×1 red PNG
- raw, _ := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
- b := Branding{LogoDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)}
- png := b.LogoPNGBytes()
- if len(png) == 0 {
- t.Fatal("expected PNG logo bytes")
- }
- if b.LogoResource() == nil {
- t.Fatal("expected LogoResource")
- }
-}
diff --git a/betterdesk-support-agent/branding_profile_test.go b/betterdesk-support-agent/branding_profile_test.go
deleted file mode 100644
index a1cc6a14..00000000
--- a/betterdesk-support-agent/branding_profile_test.go
+++ /dev/null
@@ -1,91 +0,0 @@
-package main
-
-import (
- "crypto/ed25519"
- "crypto/rand"
- "fmt"
- "testing"
- "time"
-
- "github.com/unitronix/betterdesk-support-agent/internal/brandprofile"
-)
-
-func TestReleaseBrandingRequiresValidSignedProfile(t *testing.T) {
- publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
- if err != nil {
- t.Fatal(err)
- }
- publicKeyResource, err := brandprofile.EncodePublicKey(publicKey)
- if err != nil {
- t.Fatal(err)
- }
- profile := []byte(fmt.Sprintf(`{
- "product_name":"Acme Support",
- "bundle_id":"bundle-1",
- "profile_issued_at":"2026-01-01T00:00:00Z",
- "profile_expires_at":"2099-01-01T00:00:00Z",
- "allowed_endpoints":[
- "https://support.example.test",
- "https://support.example.test/api",
- "wss://support.example.test:21122/cdap"
- ],
- "server":{
- "address":"https://support.example.test",
- "api_url":"https://support.example.test/api",
- "public_key":"%s",
- "cdap_url":"wss://support.example.test:21122/cdap"
- }
- }`, publicKeyResource))
- signed, err := brandprofile.Sign(profile, privateKey)
- if err != nil {
- t.Fatal(err)
- }
- branding, err := decodeBrandingProfile(signed, []byte(publicKeyResource), true)
- if err != nil {
- t.Fatal(err)
- }
- if branding.ProductName != "Acme Support" || branding.Server == nil ||
- branding.Server.Address != "https://support.example.test" {
- t.Fatalf("unexpected branding: %+v", branding)
- }
- branding.Server.CertPin = "invalid-pin"
- if err := branding.validateReleaseProfile(time.Now()); err == nil {
- t.Fatal("release profile unexpectedly accepted an invalid certificate pin")
- }
-
- if _, err := decodeBrandingProfile(profile, []byte(publicKeyResource), true); err == nil {
- t.Fatal("release profile unexpectedly accepted unsigned branding")
- }
- missingExpiry, err := brandprofile.Sign([]byte(`{
- "bundle_id":"bundle-1",
- "profile_issued_at":"2026-01-01T00:00:00Z",
- "allowed_endpoints":["https://support.example.test"],
- "server":{"address":"https://support.example.test","api_url":"https://support.example.test/api","cdap_url":"wss://support.example.test:21122/cdap"}
- }`), privateKey)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := decodeBrandingProfile(missingExpiry, []byte(publicKeyResource), true); err == nil {
- t.Fatal("release profile unexpectedly accepted a profile without expiry")
- }
-
- tampered := append([]byte(nil), signed...)
- tampered[len(tampered)-1] ^= 1
- if _, err := decodeBrandingProfile(tampered, []byte(publicKeyResource), true); err == nil {
- t.Fatal("release profile unexpectedly accepted tampered branding")
- }
-}
-
-func TestDevelopmentBrandingCanUsePlaintextProfile(t *testing.T) {
- branding, err := decodeBrandingProfile(
- []byte(`{"product_name":"Local Dev","server_address":"http://127.0.0.1:21114"}`),
- nil,
- false,
- )
- if err != nil {
- t.Fatal(err)
- }
- if branding.ProductName != "Local Dev" {
- t.Fatalf("unexpected branding: %+v", branding)
- }
-}
diff --git a/betterdesk-support-agent/branding_seal.go b/betterdesk-support-agent/branding_seal.go
deleted file mode 100644
index 3fdc1602..00000000
--- a/betterdesk-support-agent/branding_seal.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package main
-
-import "github.com/unitronix/betterdesk-support-agent/internal/brandseal"
-
-func sealBranding(plaintext, salt []byte) ([]byte, error) {
- return brandseal.Seal(plaintext, salt)
-}
-
-func unsealBranding(blob []byte) ([]byte, error) {
- return brandseal.Unseal(blob)
-}
-
-func isSealedBranding(blob []byte) bool {
- return brandseal.IsSealed(blob)
-}
diff --git a/betterdesk-support-agent/branding_test.go b/betterdesk-support-agent/branding_test.go
deleted file mode 100644
index 72cf92f4..00000000
--- a/betterdesk-support-agent/branding_test.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "strings"
- "testing"
-)
-
-func TestBrandingNormalizeDefaults(t *testing.T) {
- b := Branding{CompanyName: "Acme"}.normalize()
- if b.PrimaryColor != "#2563eb" {
- t.Fatalf("primary default: %s", b.PrimaryColor)
- }
- if b.BackgroundColor != "#ffffff" {
- t.Fatalf("background default: %s", b.BackgroundColor)
- }
- if b.AccentColor != "#e0f2fe" {
- t.Fatalf("accent default: %s", b.AccentColor)
- }
- if b.HeaderTextColor != "#1f2937" {
- t.Fatalf("header text default: %s", b.HeaderTextColor)
- }
- if b.StatusReadyColor != "#22c55e" {
- t.Fatalf("status color default: %s", b.StatusReadyColor)
- }
-}
-
-func TestBrandingServerNested(t *testing.T) {
- raw := `{"company_name":"X","server":{"address":"https://host:5443","api_url":"https://host:5443/api","public_key":"abc"}}`
- var b Branding
- if err := json.Unmarshal([]byte(raw), &b); err != nil {
- t.Fatal(err)
- }
- b = b.normalize()
- if b.ServerAddress != "https://host:5443" {
- t.Fatalf("address: %s", b.ServerAddress)
- }
- if b.ServerKey != "abc" {
- t.Fatalf("key: %s", b.ServerKey)
- }
-}
-
-func TestStateEnrollment(t *testing.T) {
- dir := t.TempDir()
- t.Setenv("BETTERDESK_AGENT_DATA_DIR", dir)
-
- st, err := LoadState()
- if err != nil {
- t.Fatal(err)
- }
- if err := st.SetEnrollment(EnrollmentApproved, st.DeviceID, "tok-123", ""); err != nil {
- t.Fatal(err)
- }
- if !st.IsEnrolled() {
- t.Fatal("expected enrolled")
- }
-
- st2, err := LoadState()
- if err != nil {
- t.Fatal(err)
- }
- if !st2.IsEnrolled() || st2.DeviceToken != "tok-123" {
- t.Fatalf("reload failed: %+v", st2)
- }
-
- // Empty token on approve must not wipe a stored token.
- if err := st2.SetEnrollment(EnrollmentApproved, st2.DeviceID, "", ""); err != nil {
- t.Fatal(err)
- }
- if !st2.IsEnrolled() || st2.DeviceToken != "tok-123" {
- t.Fatalf("token wiped: %+v", st2)
- }
-}
-
-func TestAPIBaseURL(t *testing.T) {
- b := Branding{
- ServerAddress: "https://desk.example.com:5443",
- Server: &ServerBranding{
- APIURL: "https://desk.example.com:5443/api",
- },
- }.normalize()
- got := apiBaseURL(b)
- want := "https://desk.example.com:5443/api"
- if got != want {
- t.Fatalf("got %q want %q", got, want)
- }
-}
-
-func TestCdapWSURLHTTPS(t *testing.T) {
- b := Branding{ServerAddress: "https://desk.example.com:5443", UseHTTPS: true}.normalize()
- got := cdapWSURL(b)
- if got[:6] != "wss://" {
- t.Fatalf("expected wss, got %q", got)
- }
- if !strings.Contains(got, ":21122/cdap") {
- t.Fatalf("expected default cdap port, got %q", got)
- }
-}
diff --git a/betterdesk-support-agent/build.sh b/betterdesk-support-agent/build.sh
deleted file mode 100755
index dcaf8e9a..00000000
--- a/betterdesk-support-agent/build.sh
+++ /dev/null
@@ -1,268 +0,0 @@
-#!/usr/bin/env bash
-# BetterDesk Support Agent — build helper.
-#
-# Produces the single self-contained binary that serves BOTH distribution
-# forms (installer + portable). The Console "Generator agenta" calls this with
-# a branding profile to bake per-deployment connection details and appearance.
-#
-# Usage:
-# ./build.sh [-b branding.json] [-o output] [-p linux|windows|darwin] [-d]
-#
-# -d Linux only: build X11 + Wayland binaries and a session-aware launcher.
-#
-# -b FILE Branding profile copied to resources/branding.json before build
-# (default: keep the checked-in unbranded profile)
-# -o FILE Output binary path (default: dist/betterdesk-support[-os])
-# -p OS Target OS (default: host OS). Default UI is Wails (WebView2 /
-# WebKit). Set BETTERDESK_SUPPORT_FYNEUI=1 for legacy Fyne builds
-# (needs OpenGL/Mesa on Windows). Cross-compiling still needs the
-# matching CGO toolchain (mingw-w64 for windows).
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-cd "$SCRIPT_DIR"
-
-BRANDING=""
-OUTPUT=""
-TARGET_OS=""
-DUAL_LINUX=0
-
-while getopts "b:o:p:dh" opt; do
- case $opt in
- b) BRANDING="$OPTARG" ;;
- o) OUTPUT="$OPTARG" ;;
- p) TARGET_OS="$OPTARG" ;;
- d) DUAL_LINUX=1 ;;
- h) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
- *) exit 1 ;;
- esac
-done
-
-GO="${GO_BIN:-go}"
-if [ -z "$TARGET_OS" ]; then
- TARGET_OS="$($GO env GOOS 2>/dev/null || uname -s | tr '[:upper:]' '[:lower:]')"
-fi
-
-SIGNING_KEY_FILE="${BETTERDESK_BUNDLE_SIGNING_KEY_FILE:-}"
-if [ -z "$SIGNING_KEY_FILE" ]; then
- echo "ERROR: release builds require BETTERDESK_BUNDLE_SIGNING_KEY_FILE" >&2
- exit 1
-fi
-if [ ! -f "$SIGNING_KEY_FILE" ]; then
- echo "ERROR: BETTERDESK_BUNDLE_SIGNING_KEY_FILE does not exist: $SIGNING_KEY_FILE" >&2
- exit 1
-fi
-
-seal_branding() {
- # Pure-Go helper — must not inherit mingw CC/CXX or host CGO from the
- # Windows cross-compile env (that compiles runtime/cgo with the wrong CC).
- local args
- args=(-in resources/branding.json -out resources/branding.json)
- if [ -n "$SIGNING_KEY_FILE" ]; then
- args+=(-signing-key-file "$SIGNING_KEY_FILE" -public-key-out resources/branding.pub)
- fi
- CGO_ENABLED=0 CC= CXX= "$GO" run ./cmd/sealbranding "${args[@]}"
-}
-
-# Bake branding (Console generator overwrites this before invoking build).
-if [ -n "$BRANDING" ]; then
- if [ ! -f "$BRANDING" ]; then
- echo "ERROR: branding file not found: $BRANDING" >&2
- exit 1
- fi
- mkdir -p resources
- dest="resources/branding.json"
- branding_abs="$(readlink -f "$BRANDING" 2>/dev/null || realpath "$BRANDING" 2>/dev/null || echo "$BRANDING")"
- dest_abs="$(readlink -f "$dest" 2>/dev/null || realpath "$dest" 2>/dev/null || echo "$(pwd)/$dest")"
- if [ "$branding_abs" != "$dest_abs" ]; then
- cp "$BRANDING" "$dest"
- fi
- echo "Baked branding from $BRANDING"
-fi
-
-EXT=""
-[ "$TARGET_OS" = "windows" ] && EXT=".exe"
-if [ -z "$OUTPUT" ]; then
- mkdir -p dist
- OUTPUT="dist/betterdesk-support-${TARGET_OS}${EXT}"
-fi
-
-# Do NOT export mingw CC/CXX here — seal_branding (and any host go run) must
-# use the native toolchain. Windows CC is applied only around the final build.
-
-# Default UI: Wails (embedded frontend/dist). Legacy Fyne remains behind the
-# fyneui build tag for emergency rebuilds.
-BUILD_TAGS="release"
-if [ "${BETTERDESK_SUPPORT_FYNEUI:-0}" = "1" ]; then
- BUILD_TAGS="release,fyneui"
- if [ "$TARGET_OS" = "windows" ] && [ -f "windows/opengl32.dll" ] && [ -f "windows/libgallium_wgl.dll" ]; then
- BUILD_TAGS="release,fyneui,mesaembed"
- echo "Legacy Fyne UI: embedding Mesa OpenGL DLLs"
- fi
-fi
-
-WINDOWS_RESOURCE=""
-WINDOWS_RESOURCE_DIR=""
-generate_windows_resources() {
- WINDOWS_RESOURCE_DIR="$(mktemp -d)"
- local icon="${WINDOWS_RESOURCE_DIR}/betterdesk-support.ico"
- local manifest="${WINDOWS_RESOURCE_DIR}/betterdesk-support.manifest"
- WINDOWS_RESOURCE="resource_windows_amd64.syso"
-
- "$GO" run ./cmd/winicon -branding resources/branding.json -out "$icon"
- cat > "$manifest" <<'EOF'
-
-
-
-
- PerMonitorV2
-
-
-
-EOF
- # rsrc emits a Windows/amd64 COFF object recognised by go build. Keeping
- # this in the module avoids a host-only windres dependency in the panel
- # build worker.
- CGO_ENABLED=0 "$GO" run github.com/akavel/rsrc \
- -arch amd64 -ico "$icon" -manifest "$manifest" -o "$WINDOWS_RESOURCE"
-}
-
-if [ ! -f "frontend/ui/index.html" ]; then
- echo "ERROR: frontend/ui/index.html missing (Wails UI assets)" >&2
- exit 1
-fi
-
-WIN_LDFLAGS="-s -w -H=windowsgui"
-
-linux_dual_build() {
- local out_dir launcher x11_bin wl_bin bak pub_bak
- out_dir="$(dirname "$OUTPUT")"
- mkdir -p "$out_dir"
- x11_bin="${out_dir}/betterdesk-support-x11"
- wl_bin="${out_dir}/betterdesk-support-wayland"
- launcher="${out_dir}/betterdesk-support"
-
- bak="$(mktemp)"
- pub_bak=""
- cp resources/branding.json "$bak"
- if [ -f resources/branding.pub ]; then
- pub_bak="$(mktemp)"
- cp resources/branding.pub "$pub_bak"
- fi
- if ! seal_branding; then
- cp "$bak" resources/branding.json
- if [ -n "$pub_bak" ] && [ -f "$pub_bak" ]; then
- cp "$pub_bak" resources/branding.pub
- fi
- if [ -n "$SIGNING_KEY_FILE" ]; then
- echo "ERROR: signed branding profile could not be created" >&2
- rm -f "$bak" "$pub_bak"
- return 1
- fi
- fi
-
- echo "Building Linux X11 UI → $x11_bin ..."
- GOOS=linux CGO_ENABLED=1 "$GO" build -trimpath -tags release -ldflags "-s -w" -o "$x11_bin" .
-
- echo "Building Linux Wayland UI → $wl_bin ..."
- GOOS=linux CGO_ENABLED=1 "$GO" build -trimpath -tags "release,wayland" -ldflags "-s -w" -o "$wl_bin" .
-
- cp "$bak" resources/branding.json
- if [ -n "$pub_bak" ] && [ -f "$pub_bak" ]; then
- cp "$pub_bak" resources/branding.pub
- fi
- rm -f "$bak"
- rm -f "$pub_bak"
-
- cp "$SCRIPT_DIR/scripts/betterdesk-support-launcher.sh" "$launcher"
- chmod +x "$launcher" "$x11_bin" "$wl_bin"
- echo "Built: $launcher (session launcher)"
- echo " $x11_bin ($(du -h "$x11_bin" | cut -f1))"
- echo " $wl_bin ($(du -h "$wl_bin" | cut -f1))"
-}
-
-if [ "$TARGET_OS" = "linux" ] && [ "$DUAL_LINUX" = 1 ]; then
- if [ "${BETTERDESK_SUPPORT_FYNEUI:-0}" = "1" ]; then
- if [ -z "$OUTPUT" ]; then
- mkdir -p dist
- OUTPUT="dist/betterdesk-support"
- fi
- linux_dual_build
- exit 0
- fi
- echo "Note: Wails UI uses a single Linux binary (ignoring -d X11/Wayland split)"
-fi
-
-# Windows ICO/manifest must be generated from plaintext branding JSON.
-# sealbranding rewrites resources/branding.json to a BDBR1 blob that winicon
-# cannot parse (json: invalid character 'B').
-restore_branding() {
- if [ -n "${BRANDING_PLAIN_BAK:-}" ] && [ -f "$BRANDING_PLAIN_BAK" ]; then
- cp "$BRANDING_PLAIN_BAK" resources/branding.json
- rm -f "$BRANDING_PLAIN_BAK"
- fi
- if [ -n "${BRANDING_PUB_BAK:-}" ] && [ -f "$BRANDING_PUB_BAK" ]; then
- cp "$BRANDING_PUB_BAK" resources/branding.pub
- rm -f "$BRANDING_PUB_BAK"
- fi
- if [ -n "${WINDOWS_RESOURCE:-}" ]; then
- rm -f "$WINDOWS_RESOURCE"
- fi
- if [ -n "${WINDOWS_RESOURCE_DIR:-}" ]; then
- rm -rf "$WINDOWS_RESOURCE_DIR"
- fi
-}
-trap restore_branding EXIT
-
-BRANDING_PLAIN_BAK=""
-BRANDING_PUB_BAK=""
-
-if [ "$TARGET_OS" = "windows" ]; then
- generate_windows_resources
-fi
-
-# Seal branding for release embeds (plaintext restored after build).
-if [ -f resources/branding.json ]; then
- BRANDING_PLAIN_BAK="$(mktemp)"
- cp resources/branding.json "$BRANDING_PLAIN_BAK"
- if [ -f resources/branding.pub ]; then
- BRANDING_PUB_BAK="$(mktemp)"
- cp resources/branding.pub "$BRANDING_PUB_BAK"
- fi
- if seal_branding; then
- echo "Signed branding profile for release embed"
- else
- echo "ERROR: branding signing failed; refusing to embed plaintext" >&2
- cp "$BRANDING_PLAIN_BAK" resources/branding.json
- if [ -n "$BRANDING_PUB_BAK" ] && [ -f "$BRANDING_PUB_BAK" ]; then
- cp "$BRANDING_PUB_BAK" resources/branding.pub
- fi
- exit 1
- fi
-fi
-
-echo "Building $OUTPUT (GOOS=$TARGET_OS) ..."
-LDFLAGS="-s -w"
-[ "$TARGET_OS" = "windows" ] && LDFLAGS="$WIN_LDFLAGS"
-
-BUILD_CMD=("$GO" build -trimpath -tags "$BUILD_TAGS" -ldflags "$LDFLAGS" -o "$OUTPUT" .)
-if [ "${BETTERDESK_USE_GARBLE:-0}" = "1" ] && command -v garble >/dev/null 2>&1; then
- echo "Using garble for release obfuscation"
- BUILD_CMD=(garble -literals -tiny build -trimpath -tags "$BUILD_TAGS" -ldflags "$LDFLAGS" -o "$OUTPUT" .)
-fi
-
-if [ "$TARGET_OS" = "windows" ]; then
- export CC="${CC:-x86_64-w64-mingw32-gcc}"
- export CXX="${CXX:-x86_64-w64-mingw32-g++}"
-fi
-GOOS="$TARGET_OS" CGO_ENABLED=1 "${BUILD_CMD[@]}"
-
-# Optional UPX pack (Windows portable) — opt-in; can trigger AV false positives.
-if [ "${BETTERDESK_USE_UPX:-0}" = "1" ] && command -v upx >/dev/null 2>&1; then
- echo "Packing with UPX…"
- upx -q --best "$OUTPUT" || echo "WARN: upx failed" >&2
-fi
-
-echo "Built: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))"
-restore_branding
-trap - EXIT
diff --git a/betterdesk-support-agent/chat.go b/betterdesk-support-agent/chat.go
deleted file mode 100644
index bf0da777..00000000
--- a/betterdesk-support-agent/chat.go
+++ /dev/null
@@ -1,11 +0,0 @@
-package main
-
-import "fmt"
-
-// SendChatMessage sends a chat message through the active CDAP engine.
-func SendChatMessage(engine *Engine, text string) error {
- if engine == nil {
- return fmt.Errorf("not connected")
- }
- return engine.SendChat(text)
-}
diff --git a/betterdesk-support-agent/chat_fyne.go b/betterdesk-support-agent/chat_fyne.go
deleted file mode 100644
index 6d4f5041..00000000
--- a/betterdesk-support-agent/chat_fyne.go
+++ /dev/null
@@ -1,12 +0,0 @@
-//go:build fyneui
-
-package main
-
-// sendChatMessage delivers a chat line via CDAP.
-func (u *ui) sendChatMessage(text string) {
- if err := SendChatMessage(u.engine, text); err != nil {
- u.notify(err.Error())
- return
- }
- u.chatMessages = append(u.chatMessages, "You: "+text)
-}
diff --git a/betterdesk-support-agent/chat_history.go b/betterdesk-support-agent/chat_history.go
deleted file mode 100644
index 79b34257..00000000
--- a/betterdesk-support-agent/chat_history.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package main
-
-import (
- "fmt"
- "net/http"
-)
-
-type chatHistoryMessage struct {
- FromName string `json:"from_name"`
- FromID string `json:"from_id"`
- Text string `json:"text"`
-}
-
-type chatHistoryResponse struct {
- Messages []chatHistoryMessage `json:"messages"`
-}
-
-// LoadChatHistory fetches prior messages for this device's conversation.
-func LoadChatHistory(b Branding, st *AppState) ([]string, error) {
- deviceID, _, _, _ := st.Snapshot()
- url := fmt.Sprintf("%s/chat/history/%s?limit=50", apiBaseURL(b), deviceID)
- var resp chatHistoryResponse
- code, err := apiJSON(http.MethodGet, url, nil, &resp)
- if err != nil {
- return nil, err
- }
- if code != http.StatusOK {
- return nil, fmt.Errorf("chat history HTTP %d", code)
- }
- out := make([]string, 0, len(resp.Messages))
- for _, m := range resp.Messages {
- from := m.FromName
- if from == "" {
- from = m.FromID
- }
- if from == deviceID {
- from = "You"
- }
- out = append(out, from+": "+m.Text)
- }
- return out, nil
-}
diff --git a/betterdesk-support-agent/chat_ui.go b/betterdesk-support-agent/chat_ui.go
deleted file mode 100644
index 63f44f31..00000000
--- a/betterdesk-support-agent/chat_ui.go
+++ /dev/null
@@ -1,62 +0,0 @@
-//go:build fyneui
-
-package main
-
-import (
- "fyne.io/fyne/v2"
- "fyne.io/fyne/v2/container"
- "fyne.io/fyne/v2/dialog"
- "fyne.io/fyne/v2/widget"
-)
-
-// showChatWindow opens the support chat dialog (implemented in chat_ui.go phase 3).
-func (u *ui) showChatWindow() {
- if u.chatWindow != nil {
- u.chatWindow.Show()
- u.chatWindow.RequestFocus()
- return
- }
- u.openChatDialog()
-}
-
-func (u *ui) openChatDialog() {
- if len(u.chatMessages) == 0 && u.brand.HasConnection() {
- if lines, err := LoadChatHistory(u.brand, u.state); err == nil {
- u.chatMessages = append(u.chatMessages, lines...)
- }
- }
- list := widget.NewList(
- func() int { return len(u.chatMessages) },
- func() fyne.CanvasObject {
- return widget.NewLabel("template")
- },
- func(i widget.ListItemID, o fyne.CanvasObject) {
- o.(*widget.Label).SetText(u.chatMessages[i])
- },
- )
- if len(u.chatMessages) == 0 {
- list.Hide()
- }
- entry := widget.NewEntry()
- entry.SetPlaceHolder(t("chat_placeholder"))
- send := func() {
- text := entry.Text
- if text == "" {
- return
- }
- entry.SetText("")
- u.sendChatMessage(text)
- list.Refresh()
- }
- entry.OnSubmitted = func(_ string) { send() }
- sendBtn := widget.NewButton(t("chat_send"), send)
- inputRow := container.NewBorder(nil, nil, nil, sendBtn, entry)
- body := container.NewBorder(nil, inputRow, nil, nil, list)
- if len(u.chatMessages) == 0 {
- empty := widget.NewLabelWithStyle(t("chat_empty"), fyne.TextAlignCenter, fyne.TextStyle{Italic: true})
- body = container.NewBorder(nil, inputRow, nil, nil, empty)
- }
- d := dialog.NewCustom(t("chat_title"), t("close"), body, u.win)
- d.Resize(fyne.NewSize(460, 420))
- d.Show()
-}
diff --git a/betterdesk-support-agent/cmd/sealbranding/main.go b/betterdesk-support-agent/cmd/sealbranding/main.go
deleted file mode 100644
index 73d8ed7b..00000000
--- a/betterdesk-support-agent/cmd/sealbranding/main.go
+++ /dev/null
@@ -1,105 +0,0 @@
-// Seal branding.json into an encrypted blob for release embeds.
-package main
-
-import (
- "crypto/ed25519"
- "crypto/rand"
- "crypto/x509"
- "encoding/base64"
- "encoding/pem"
- "flag"
- "fmt"
- "os"
- "strings"
-
- "github.com/unitronix/betterdesk-support-agent/internal/brandprofile"
- "github.com/unitronix/betterdesk-support-agent/internal/brandseal"
-)
-
-func main() {
- in := flag.String("in", "resources/branding.json", "input branding JSON")
- out := flag.String("out", "", "output path (default: overwrite -in)")
- signingKeyFile := flag.String("signing-key-file", "", "Ed25519 PKCS#8 PEM or base64 private key file")
- publicKeyOut := flag.String("public-key-out", "resources/branding.pub", "output file for the signing public key")
- flag.Parse()
- if *out == "" {
- *out = *in
- }
- plain, err := os.ReadFile(*in)
- if err != nil {
- fmt.Fprintf(os.Stderr, "read: %v\n", err)
- os.Exit(1)
- }
- if brandprofile.IsSigned(plain) || brandseal.IsSealed(plain) {
- fmt.Println("branding already sealed; skipping")
- return
- }
- if *signingKeyFile != "" {
- privateKey, err := loadPrivateKey(*signingKeyFile)
- if err != nil {
- fmt.Fprintf(os.Stderr, "signing key: %v\n", err)
- os.Exit(1)
- }
- signed, err := brandprofile.Sign(plain, privateKey)
- if err != nil {
- fmt.Fprintf(os.Stderr, "sign branding: %v\n", err)
- os.Exit(1)
- }
- encodedPublic, err := brandprofile.EncodePublicKey(privateKey.Public().(ed25519.PublicKey))
- if err != nil {
- fmt.Fprintf(os.Stderr, "encode signing public key: %v\n", err)
- os.Exit(1)
- }
- if err := os.WriteFile(*publicKeyOut, []byte(encodedPublic+"\n"), 0o644); err != nil {
- fmt.Fprintf(os.Stderr, "write signing public key: %v\n", err)
- os.Exit(1)
- }
- if err := os.WriteFile(*out, signed, 0o644); err != nil {
- fmt.Fprintf(os.Stderr, "write: %v\n", err)
- os.Exit(1)
- }
- fmt.Printf("signed branding → %s (%d bytes)\n", *out, len(signed))
- return
- }
- salt := make([]byte, 32)
- if _, err := rand.Read(salt); err != nil {
- fmt.Fprintf(os.Stderr, "salt: %v\n", err)
- os.Exit(1)
- }
- sealed, err := brandseal.Seal(plain, salt)
- if err != nil {
- fmt.Fprintf(os.Stderr, "seal: %v\n", err)
- os.Exit(1)
- }
- if err := os.WriteFile(*out, sealed, 0o644); err != nil {
- fmt.Fprintf(os.Stderr, "write: %v\n", err)
- os.Exit(1)
- }
- fmt.Printf("sealed branding → %s (%d bytes)\n", *out, len(sealed))
-}
-
-func loadPrivateKey(path string) (ed25519.PrivateKey, error) {
- data, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- if block, _ := pem.Decode(data); block != nil {
- parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
- if err != nil {
- return nil, fmt.Errorf("parse PKCS#8 PEM: %w", err)
- }
- key, ok := parsed.(ed25519.PrivateKey)
- if !ok {
- return nil, fmt.Errorf("PEM key is not Ed25519")
- }
- return key, nil
- }
- raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data)))
- if err != nil {
- return nil, fmt.Errorf("decode base64 private key: %w", err)
- }
- if len(raw) != ed25519.PrivateKeySize {
- return nil, fmt.Errorf("invalid Ed25519 private key length")
- }
- return ed25519.PrivateKey(raw), nil
-}
diff --git a/betterdesk-support-agent/cmd/sealbranding/main_test.go b/betterdesk-support-agent/cmd/sealbranding/main_test.go
deleted file mode 100644
index 8090dc72..00000000
--- a/betterdesk-support-agent/cmd/sealbranding/main_test.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package main
-
-import (
- "crypto/ed25519"
- "crypto/rand"
- "crypto/x509"
- "encoding/pem"
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestLoadPrivateKeyPKCS8PEM(t *testing.T) {
- _, privateKey, err := ed25519.GenerateKey(rand.Reader)
- if err != nil {
- t.Fatal(err)
- }
- der, err := x509.MarshalPKCS8PrivateKey(privateKey)
- if err != nil {
- t.Fatal(err)
- }
- path := filepath.Join(t.TempDir(), "branding.key")
- if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600); err != nil {
- t.Fatal(err)
- }
- got, err := loadPrivateKey(path)
- if err != nil {
- t.Fatal(err)
- }
- if !privateKey.Equal(got) {
- t.Fatal("loaded key differs from source")
- }
-}
diff --git a/betterdesk-support-agent/cmd/winicon/main.go b/betterdesk-support-agent/cmd/winicon/main.go
deleted file mode 100644
index 4938e90d..00000000
--- a/betterdesk-support-agent/cmd/winicon/main.go
+++ /dev/null
@@ -1,113 +0,0 @@
-// Command winicon creates a Windows ICO resource from a Support Agent branding
-// profile. It intentionally accepts PNG/JPEG logos only: unsupported artwork
-// falls back to a recognizable BetterDesk-blue icon instead of producing an
-// unbranded Windows executable.
-package main
-
-import (
- "bytes"
- "encoding/base64"
- "encoding/json"
- "flag"
- "fmt"
- "image"
- "image/color"
- "image/draw"
- _ "image/jpeg"
- _ "image/png"
- "os"
- "strings"
-
- "github.com/fyne-io/image/ico"
-)
-
-type branding struct {
- LogoDataURL string `json:"logo_data_url"`
- PrimaryColor string `json:"primary_color"`
-}
-
-func main() {
- in := flag.String("branding", "", "branding JSON path")
- out := flag.String("out", "", "output ICO path")
- flag.Parse()
- if *in == "" || *out == "" {
- fmt.Fprintln(os.Stderr, "usage: winicon -branding branding.json -out app.ico")
- os.Exit(2)
- }
-
- raw, err := os.ReadFile(*in)
- if err != nil {
- fail(err)
- }
- var b branding
- if err := json.Unmarshal(raw, &b); err != nil {
- fail(err)
- }
-
- img := decodeLogo(b.LogoDataURL)
- if img == nil {
- img = fallbackIcon(b.PrimaryColor)
- }
- var buf bytes.Buffer
- if err := ico.Encode(&buf, img); err != nil {
- fail(err)
- }
- if err := os.WriteFile(*out, buf.Bytes(), 0o644); err != nil {
- fail(err)
- }
-}
-
-func decodeLogo(dataURL string) image.Image {
- parts := strings.SplitN(dataURL, ",", 2)
- if len(parts) != 2 || !strings.Contains(parts[0], ";base64") {
- return nil
- }
- data, err := base64.StdEncoding.DecodeString(parts[1])
- if err != nil {
- return nil
- }
- img, _, err := image.Decode(bytes.NewReader(data))
- if err != nil {
- return nil
- }
- return img
-}
-
-func fallbackIcon(primary string) image.Image {
- c := color.RGBA{R: 37, G: 99, B: 235, A: 255}
- if parsed, ok := parseHex(primary); ok {
- c = parsed
- }
- img := image.NewRGBA(image.Rect(0, 0, 256, 256))
- draw.Draw(img, img.Bounds(), &image.Uniform{C: c}, image.Point{}, draw.Src)
- // A minimal white “B” mark remains identifiable at notification-area size.
- white := color.RGBA{255, 255, 255, 255}
- for _, r := range []image.Rectangle{
- image.Rect(70, 52, 94, 204),
- image.Rect(94, 52, 170, 76),
- image.Rect(94, 116, 170, 140),
- image.Rect(94, 180, 170, 204),
- image.Rect(154, 68, 178, 124),
- image.Rect(154, 132, 178, 188),
- } {
- draw.Draw(img, r, &image.Uniform{C: white}, image.Point{}, draw.Src)
- }
- return img
-}
-
-func parseHex(value string) (color.RGBA, bool) {
- value = strings.TrimPrefix(strings.TrimSpace(value), "#")
- if len(value) != 6 {
- return color.RGBA{}, false
- }
- var r, g, b uint8
- if _, err := fmt.Sscanf(value, "%02x%02x%02x", &r, &g, &b); err != nil {
- return color.RGBA{}, false
- }
- return color.RGBA{R: r, G: g, B: b, A: 255}, true
-}
-
-func fail(err error) {
- fmt.Fprintln(os.Stderr, "winicon:", err)
- os.Exit(1)
-}
diff --git a/betterdesk-support-agent/cmd/winicon/main_test.go b/betterdesk-support-agent/cmd/winicon/main_test.go
deleted file mode 100644
index ce23dcb0..00000000
--- a/betterdesk-support-agent/cmd/winicon/main_test.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package main
-
-import (
- "image/color"
- "testing"
-)
-
-func TestFallbackIconUsesBrandPrimaryColor(t *testing.T) {
- img := fallbackIcon("#123456")
- got := color.RGBAModel.Convert(img.At(10, 10)).(color.RGBA)
- if got != (color.RGBA{R: 0x12, G: 0x34, B: 0x56, A: 0xff}) {
- t.Fatalf("fallback colour = %#v", got)
- }
-}
-
-func TestParseHexRejectsInvalidBrandColor(t *testing.T) {
- if _, ok := parseHex("not-a-colour"); ok {
- t.Fatal("invalid colour accepted")
- }
-}
diff --git a/betterdesk-support-agent/color_util.go b/betterdesk-support-agent/color_util.go
deleted file mode 100644
index b1047e47..00000000
--- a/betterdesk-support-agent/color_util.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package main
-
-import (
- "image/color"
- "strconv"
- "strings"
-)
-
-func parseHexColor(s string, fallback color.RGBA) color.RGBA {
- s = strings.TrimSpace(s)
- s = strings.TrimPrefix(s, "#")
- switch len(s) {
- case 3:
- s = string([]byte{s[0], s[0], s[1], s[1], s[2], s[2]})
- case 6, 8:
- default:
- return fallback
- }
- val, err := strconv.ParseUint(s[:6], 16, 32)
- if err != nil {
- return fallback
- }
- out := color.RGBA{
- R: uint8(val >> 16),
- G: uint8(val >> 8),
- B: uint8(val),
- A: 0xff,
- }
- if len(s) == 8 {
- a, err := strconv.ParseUint(s[6:8], 16, 8)
- if err == nil {
- out.A = uint8(a)
- }
- }
- return out
-}
-
-func mustRGBA(hex string) color.RGBA {
- return parseHexColor(hex, color.RGBA{R: 0x25, G: 0x63, B: 0xeb, A: 0xff})
-}
diff --git a/betterdesk-support-agent/crypto.go b/betterdesk-support-agent/crypto.go
deleted file mode 100644
index 0c220a21..00000000
--- a/betterdesk-support-agent/crypto.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package main
-
-import (
- "crypto/aes"
- "crypto/cipher"
- "crypto/rand"
- "crypto/sha256"
- "fmt"
- "io"
-)
-
-// State-at-rest encryption.
-//
-// The agent's local state (device identity + access password) must not be
-// trivially readable or, more importantly, copyable to another machine to
-// impersonate this device. The state file is therefore encrypted with
-// AES-256-GCM using a key derived from a platform-stable machine identifier
-// (the same seed that anchors the device ID). Because the key never leaves the
-// machine and is bound to it, a state file copied elsewhere fails to decrypt
-// and the agent regenerates a fresh identity instead of cloning this one.
-
-// stateMagic prefixes every encrypted state blob so the loader can tell an
-// encrypted file from a legacy plaintext one.
-var stateMagic = []byte("BDSE1\x00")
-
-// stateKey derives the 32-byte AES key bound to this machine. The machine seed
-// is mixed with a domain-separation label so the key cannot collide with other
-// machine-seed uses (e.g. the device ID derivation).
-func stateKey() [32]byte {
- seed := legacyMachineSeed()
- if seed == "" {
- // No stable machine identifier: fall back to a hostname-less constant.
- // State stays encrypted but is effectively portable on this host only.
- seed = "betterdesk-support-no-machine-id"
- }
- return sha256.Sum256([]byte("betterdesk-support-state-key-v1|" + seed))
-}
-
-// encryptState seals plaintext into a machine-bound envelope:
-// magic | nonce(12) | ciphertext+tag.
-func encryptState(plaintext []byte) ([]byte, error) {
- key := stateKey()
- block, err := aes.NewCipher(key[:])
- if err != nil {
- return nil, err
- }
- gcm, err := cipher.NewGCM(block)
- if err != nil {
- return nil, err
- }
- nonce := make([]byte, gcm.NonceSize())
- if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
- return nil, err
- }
- sealed := gcm.Seal(nil, nonce, plaintext, stateMagic)
-
- out := make([]byte, 0, len(stateMagic)+len(nonce)+len(sealed))
- out = append(out, stateMagic...)
- out = append(out, nonce...)
- out = append(out, sealed...)
- return out, nil
-}
-
-// decryptState opens an envelope produced by encryptState. It returns an error
-// when the blob is not encrypted, is truncated, or was sealed on a different
-// machine (authentication fails).
-func decryptState(blob []byte) ([]byte, error) {
- if !isEncryptedState(blob) {
- return nil, fmt.Errorf("not an encrypted state blob")
- }
- body := blob[len(stateMagic):]
-
- key := stateKey()
- block, err := aes.NewCipher(key[:])
- if err != nil {
- return nil, err
- }
- gcm, err := cipher.NewGCM(block)
- if err != nil {
- return nil, err
- }
- ns := gcm.NonceSize()
- if len(body) < ns {
- return nil, fmt.Errorf("state blob too short")
- }
- nonce, ciphertext := body[:ns], body[ns:]
- plaintext, err := gcm.Open(nil, nonce, ciphertext, stateMagic)
- if err != nil {
- return nil, fmt.Errorf("decrypt state (wrong machine or corrupted): %w", err)
- }
- return plaintext, nil
-}
-
-// isEncryptedState reports whether the blob carries the encrypted-state magic.
-func isEncryptedState(blob []byte) bool {
- if len(blob) < len(stateMagic) {
- return false
- }
- for i := range stateMagic {
- if blob[i] != stateMagic[i] {
- return false
- }
- }
- return true
-}
diff --git a/betterdesk-support-agent/crypto_identity_test.go b/betterdesk-support-agent/crypto_identity_test.go
deleted file mode 100644
index 37985937..00000000
--- a/betterdesk-support-agent/crypto_identity_test.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package main
-
-import (
- "bytes"
- "testing"
-)
-
-func TestStateEncryptionRoundTrip(t *testing.T) {
- plain := []byte(`{"device_id":"BD-TEST","access_password":"secret12"}`)
- blob, err := encryptState(plain)
- if err != nil {
- t.Fatalf("encrypt: %v", err)
- }
- if !isEncryptedState(blob) {
- t.Fatal("expected encrypted magic prefix")
- }
- out, err := decryptState(blob)
- if err != nil {
- t.Fatalf("decrypt: %v", err)
- }
- if !bytes.Equal(out, plain) {
- t.Fatalf("round-trip mismatch: %q vs %q", out, plain)
- }
-}
-
-func TestStateEncryptionTamperFails(t *testing.T) {
- plain := []byte(`{"device_id":"BD-TEST"}`)
- blob, err := encryptState(plain)
- if err != nil {
- t.Fatalf("encrypt: %v", err)
- }
- // Flip a ciphertext byte — GCM auth must fail (anti-clone / tamper).
- blob[len(blob)-1] ^= 0xff
- if _, err := decryptState(blob); err == nil {
- t.Fatal("expected decrypt failure after tamper")
- }
-}
-
-func TestCandidateEndpointsPreferLastGood(t *testing.T) {
- b := Branding{
- ServerAddress: "https://primary.example.com",
- UseHTTPS: true,
- Server: &ServerBranding{
- Address: "https://primary.example.com",
- APIURL: "https://primary.example.com/api",
- CDAPURL: "wss://primary.example.com:21122/cdap",
- },
- }
- st := &AppState{
- LastGoodCDAP: "wss://fallback.example.com:21122/cdap",
- LastGoodAPI: "https://fallback.example.com/api",
- }
- cdap := CandidateCDAPWebSockets(b, st)
- if len(cdap) < 2 {
- t.Fatalf("expected multiple CDAP candidates, got %v", cdap)
- }
- if cdap[0] != st.LastGoodCDAP {
- t.Fatalf("last-good CDAP should be first, got %q", cdap[0])
- }
- api := CandidateAPIBases(b, st)
- if api[0] != st.LastGoodAPI {
- t.Fatalf("last-good API should be first, got %q", api[0])
- }
-}
-
-func TestHealthURLFromCDAPWS(t *testing.T) {
- got := healthURLFromCDAPWS("wss://host.example:21122/cdap")
- want := "https://host.example:21122/cdap/health"
- if got != want {
- t.Fatalf("got %q want %q", got, want)
- }
-}
diff --git a/betterdesk-support-agent/debuglog.go b/betterdesk-support-agent/debuglog.go
deleted file mode 100644
index b0fd1317..00000000
--- a/betterdesk-support-agent/debuglog.go
+++ /dev/null
@@ -1,60 +0,0 @@
-//go:build !release
-
-package main
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "time"
-)
-
-const debugSessionID = "7fbd11"
-
-// debugLog writes NDJSON diagnostics for debug-mode investigation.
-// Never log secrets (tokens, passwords) — use lengths and booleans only.
-func debugLog(hypothesisID, location, message string, data map[string]any) {
- if data == nil {
- data = map[string]any{}
- }
- payload := map[string]any{
- "sessionId": debugSessionID,
- "timestamp": time.Now().UnixMilli(),
- "hypothesisId": hypothesisID,
- "location": location,
- "message": message,
- "data": data,
- }
- line, err := json.Marshal(payload)
- if err != nil {
- return
- }
- line = append(line, '\n')
-
- paths := []string{}
- if p := os.Getenv("BETTERDESK_DEBUG_LOG"); p != "" {
- paths = append(paths, p)
- }
- paths = append(paths, filepath.Join(stateDir(), "debug-"+debugSessionID+".log"))
-
- seen := map[string]bool{}
- for _, p := range paths {
- if p == "" || seen[p] {
- continue
- }
- seen[p] = true
- if dir := filepath.Dir(p); dir != "" {
- _ = os.MkdirAll(dir, 0o700)
- }
- f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
- if err != nil {
- continue
- }
- if err := f.Chmod(0o600); err != nil {
- _ = f.Close()
- continue
- }
- _, _ = f.Write(line)
- _ = f.Close()
- }
-}
diff --git a/betterdesk-support-agent/debuglog_release.go b/betterdesk-support-agent/debuglog_release.go
deleted file mode 100644
index 8c63fe6e..00000000
--- a/betterdesk-support-agent/debuglog_release.go
+++ /dev/null
@@ -1,7 +0,0 @@
-//go:build release
-
-package main
-
-// debugLog is intentionally compiled out of release builds. Diagnostic logs
-// can include endpoint and device metadata that is not needed at runtime.
-func debugLog(string, string, string, map[string]any) {}
diff --git a/betterdesk-support-agent/debuglog_test.go b/betterdesk-support-agent/debuglog_test.go
deleted file mode 100644
index a3fdf896..00000000
--- a/betterdesk-support-agent/debuglog_test.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package main
-
-import (
- "os"
- "path/filepath"
- "runtime"
- "testing"
-)
-
-func TestDebugLogIsDisabledInReleaseAndPrivateInDevelopment(t *testing.T) {
- dir := t.TempDir()
- t.Setenv("BETTERDESK_AGENT_DATA_DIR", dir)
- t.Setenv("BETTERDESK_DEBUG_LOG", "")
-
- debugLog("test", "test", "test", nil)
- path := filepath.Join(dir, "debug-7fbd11.log")
- info, err := os.Stat(path)
-
- if isReleaseBuild() {
- if !os.IsNotExist(err) {
- t.Fatalf("release build wrote debug log: %v", err)
- }
- return
- }
- if err != nil {
- t.Fatalf("development build did not write debug log: %v", err)
- }
- if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
- t.Fatalf("debug log permissions = %o, want 0600", info.Mode().Perm())
- }
-}
diff --git a/betterdesk-support-agent/engine.go b/betterdesk-support-agent/engine.go
deleted file mode 100644
index e84baf33..00000000
--- a/betterdesk-support-agent/engine.go
+++ /dev/null
@@ -1,283 +0,0 @@
-package main
-
-import (
- "errors"
- "fmt"
- "log"
- "os"
- "strings"
- "sync"
- "time"
-
- bdagent "github.com/unitronix/betterdesk-agent/agent"
-)
-
-var errRemoteAccessDisabled = errors.New("remote access is disabled by local policy")
-
-// Engine wraps the shared betterdesk-agent remote-desktop engine.
-type Engine struct {
- mu sync.Mutex
- agent *bdagent.Agent
- running bool
- version string
- onConsent func(sessionID, operator string) bool
- onSessionStart func(sessionID, operator, mode string)
- onSessionEnd func(sessionID string)
- onChat func(from, text string)
- sessionAuthorizer *passiveSessionAuthorizer
-}
-
-// NewEngine creates an engine wrapper.
-func NewEngine(version string) *Engine {
- return &Engine{version: version}
-}
-
-// SetCallbacks wires UI handlers for consent and session overlay.
-func (e *Engine) SetCallbacks(
- onConsent func(sessionID, operator string) bool,
- onSessionStart func(sessionID, operator, mode string),
- onSessionEnd func(sessionID string),
-) {
- e.mu.Lock()
- defer e.mu.Unlock()
- e.onConsent = onConsent
- e.onSessionStart = onSessionStart
- e.onSessionEnd = onSessionEnd
-}
-
-func buildConfig(b Branding, st *AppState, version string, handlers *Engine) (*bdagent.Config, error) {
- if !b.HasConnection() {
- return nil, fmt.Errorf("branding has no server address; cannot connect")
- }
- if !st.IsEnrolled() {
- return nil, fmt.Errorf("device not enrolled")
- }
- policy := accessPolicyFor(b, st)
- if policy.mode == AccessDisabled {
- // Do not keep a CDAP registration alive merely to reject individual
- // requests. AccessDisabled is an explicit local opt-out for both
- // incoming transports.
- return nil, errRemoteAccessDisabled
- }
-
- st.mu.Lock()
- token := st.DeviceToken
- deviceID := st.DeviceID
- st.mu.Unlock()
-
- cfg := bdagent.DefaultConfig()
- cdapWS, _ := PickWorkingCDAP(b, st)
- cfg.Server = cdapWS
- cfg.AuthMethod = "device_token"
- cfg.DeviceToken = token
- cfg.DeviceID = deviceID
- cfg.DeviceType = "os_agent"
- if h, err := os.Hostname(); err == nil && h != "" {
- cfg.DeviceName = h
- }
-
- cfg.Tags = []string{"support-agent"}
- if IsPortable() {
- cfg.Tags = append(cfg.Tags, "portable")
- } else {
- cfg.Tags = append(cfg.Tags, "installed")
- }
- if b.BundleID != "" {
- cfg.Tags = append(cfg.Tags, "bundle:"+b.BundleID)
- }
-
- caps := policy.capabilities
- cfg.Screenshot = caps.Desktop
- cfg.Terminal = caps.Terminal
- cfg.Clipboard = caps.Clipboard
- cfg.FileBrowser = caps.Files
- if home, err := os.UserHomeDir(); err == nil && home != "" {
- cfg.FileRoot = home
- }
-
- cfg.RequireConsent = policy.requiresConsent()
-
- if strings.HasPrefix(strings.TrimSpace(b.ServerAddress), "https://") || b.useTLS() {
- cfg.EnforceTLS = true
- }
- if b.Server != nil {
- cfg.ServerCertPin = strings.TrimSpace(b.Server.CertPin)
- }
- if tlsInsecureEnabled() && cfg.ServerCertPin == "" {
- cfg.TLSInsecureSkipVerify = true
- }
-
- if handlers != nil {
- authorizer := handlers.sessionAuthorizer
- if handlers.onConsent != nil {
- cfg.ConsentHandler = func(sessionID, operator string) bool {
- granted := handlers.onConsent(sessionID, operator)
- if authorizer != nil {
- authorizer.ResolveConsent(sessionID, granted)
- }
- return granted
- }
- }
- if authorizer != nil {
- requireConsent := policy.requiresConsent()
- cfg.SessionAuthorizeHandler = func(sessionID, operator, transport string, capabilities []string, grant string) error {
- return authorizer.Authorize(sessionID, operator, transport, capabilities, grant, requireConsent)
- }
- }
- if handlers.onSessionStart != nil {
- cfg.SessionStartHandler = handlers.onSessionStart
- }
- if handlers.onSessionEnd != nil {
- cfg.SessionEndHandler = func(sessionID string) {
- if authorizer != nil {
- authorizer.End(sessionID)
- }
- handlers.onSessionEnd(sessionID)
- }
- }
- if handlers.onChat != nil {
- cfg.ChatMessageHandler = handlers.onChat
- }
- }
-
- if err := cfg.Validate(); err != nil {
- return nil, err
- }
- return cfg, nil
-}
-
-// cdapWSURL builds the CDAP WebSocket URL from branding.
-func cdapWSURL(b Branding) string {
- return b.CDAPWebSocketURL()
-}
-
-// Start launches the engine when enrolled.
-func (e *Engine) Start(st *AppState) error {
- e.mu.Lock()
- defer e.mu.Unlock()
- if e.running {
- return nil
- }
- if !st.IsEnrolled() {
- return fmt.Errorf("enrollment required before starting engine")
- }
- if e.sessionAuthorizer == nil {
- authorizer, err := newPassiveSessionAuthorizer(GetBranding(), st)
- if err != nil {
- return err
- }
- e.sessionAuthorizer = authorizer
- }
-
- cfg, err := buildConfig(GetBranding(), st, e.version, e)
- if err != nil {
- return err
- }
-
- a := bdagent.New(cfg, e.version)
- e.agent = a
- e.running = true
-
- go func() {
- if err := a.Run(); err != nil {
- log.Printf("[engine] stopped: %v", err)
- }
- e.mu.Lock()
- e.running = false
- e.mu.Unlock()
- }()
- return nil
-}
-
-// Stop signals the engine to shut down.
-func (e *Engine) Stop() {
- e.mu.Lock()
- a := e.agent
- e.mu.Unlock()
- if a != nil {
- a.Stop()
- }
- // A local disconnect is an authorization boundary, not merely a transport
- // pause. Drop the prior session arbiter so a later reconnect must obtain
- // and validate a fresh server grant.
- e.mu.Lock()
- if e.agent == a {
- e.agent = nil
- e.running = false
- e.sessionAuthorizer = nil
- }
- e.mu.Unlock()
-}
-
-// Restart applies a changed local access policy after the active agent exits.
-// Waiting for the old Run loop avoids racing a fresh configuration against an
-// agent that is still connected with the previous permissions.
-func (e *Engine) Restart(st *AppState) error {
- e.Stop()
-
- deadline := time.NewTimer(5 * time.Second)
- defer deadline.Stop()
- ticker := time.NewTicker(50 * time.Millisecond)
- defer ticker.Stop()
- for e.Running() {
- select {
- case <-deadline.C:
- return fmt.Errorf("remote engine did not stop after access policy change")
- case <-ticker.C:
- }
- }
- return e.Start(st)
-}
-
-// Running reports whether the engine goroutine is active.
-func (e *Engine) Running() bool {
- e.mu.Lock()
- defer e.mu.Unlock()
- return e.running
-}
-
-// SendChat sends a chat message when the engine is running.
-func (e *Engine) SendChat(text string) error {
- e.mu.Lock()
- a := e.agent
- e.mu.Unlock()
- if a == nil {
- return fmt.Errorf("gateway not connected")
- }
- return a.SendChat(text)
-}
-
-// SetChatHandler wires incoming chat messages to the UI.
-func (e *Engine) SetChatHandler(fn func(from, text string)) {
- e.mu.Lock()
- defer e.mu.Unlock()
- e.onChat = fn
-}
-
-func (e *Engine) RequestHelp(st *AppState, message string) error {
- e.mu.Lock()
- a := e.agent
- running := e.running
- e.mu.Unlock()
- if !running || a == nil {
- if err := e.Start(st); err != nil {
- return fmt.Errorf("connect to gateway: %w", err)
- }
- for i := 0; i < 40; i++ {
- e.mu.Lock()
- a = e.agent
- e.mu.Unlock()
- if a != nil && a.Connected() {
- break
- }
- time.Sleep(250 * time.Millisecond)
- }
- e.mu.Lock()
- a = e.agent
- e.mu.Unlock()
- }
- if a == nil {
- return fmt.Errorf("gateway not connected")
- }
- return a.RequestHelp(message)
-}
diff --git a/betterdesk-support-agent/engine_policy_test.go b/betterdesk-support-agent/engine_policy_test.go
deleted file mode 100644
index 1182b4f8..00000000
--- a/betterdesk-support-agent/engine_policy_test.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package main
-
-import (
- "errors"
- "testing"
-)
-
-func TestBuildConfigDoesNotRegisterWhenAccessIsDisabled(t *testing.T) {
- st := &AppState{
- DeviceID: "BD-TEST",
- DeviceToken: "device-token",
- EnrollmentStatus: EnrollmentApproved,
- AccessMode: AccessDisabled,
- AccessPassword: "secret12",
- }
-
- cfg, err := buildConfig(Branding{ServerAddress: "https://127.0.0.1:1"}, st, "test", nil)
- if !errors.Is(err, errRemoteAccessDisabled) {
- t.Fatalf("buildConfig error = %v, want disabled access error", err)
- }
- if cfg != nil {
- t.Fatalf("buildConfig config = %#v, want nil when access is disabled", cfg)
- }
-}
diff --git a/betterdesk-support-agent/enrollment.go b/betterdesk-support-agent/enrollment.go
deleted file mode 100644
index 87823ee5..00000000
--- a/betterdesk-support-agent/enrollment.go
+++ /dev/null
@@ -1,390 +0,0 @@
-package main
-
-import (
- "fmt"
- "net/http"
- "net/url"
- "os"
- "runtime"
- "strings"
- "time"
-)
-
-const (
- EnrollmentApproved = "approved"
- EnrollmentPending = "pending"
- EnrollmentRejected = "rejected"
-)
-
-// enrollmentResponse mirrors betterdesk-server/api/branding_handlers.go.
-type enrollmentResponse struct {
- Status string `json:"status"`
- DeviceID string `json:"device_id"`
- DeviceToken string `json:"device_token,omitempty"`
- Message string `json:"message,omitempty"`
- Error string `json:"error,omitempty"`
- SuggestedDeviceID string `json:"suggested_device_id,omitempty"`
-}
-
-// EnrollmentStatus is the outcome of register or poll.
-type EnrollmentStatus struct {
- Status string
- DeviceID string
- DeviceToken string
- Message string
-}
-
-// EnsureEnrolled registers the device when needed and returns the current status.
-// When already approved with a token, returns immediately.
-func EnsureEnrolled(b Branding, st *AppState, version string) (EnrollmentStatus, error) {
- if !b.HasConnection() {
- // #region agent log
- debugLog("H5", "enrollment.go:EnsureEnrolled", "no connection in branding", map[string]any{
- "server_address": b.ServerAddress, "has_server_block": b.Server != nil,
- })
- // #endregion
- return EnrollmentStatus{}, fmt.Errorf("no server address configured")
- }
-
- st.mu.Lock()
- status := st.EnrollmentStatus
- token := st.DeviceToken
- deviceID := st.DeviceID
- message := st.EnrollmentMessage
- st.mu.Unlock()
-
- // #region agent log
- debugLog("H4", "enrollment.go:EnsureEnrolled", "entry", map[string]any{
- "local_status": status, "device_id": deviceID,
- "has_local_token": token != "", "is_enrolled": st.IsEnrolled(),
- })
- // #endregion
-
- if status == EnrollmentApproved {
- if token != "" {
- // Refresh credentials with the server on every startup. Re-register
- // re-issues a device_token for known peers and fixes stale local state.
- res, err := RegisterDevice(b, st, version)
- if err == nil || res.Status == EnrollmentRejected {
- return res, err
- }
- // #region agent log
- debugLog("H4", "enrollment.go:EnsureEnrolled", "refresh failed, using cached token", map[string]any{
- "device_id": deviceID,
- })
- // #endregion
- return EnrollmentStatus{
- Status: EnrollmentApproved,
- DeviceID: deviceID,
- DeviceToken: token,
- }, nil
- }
- return RegisterDevice(b, st, version)
- }
-
- if status == EnrollmentRejected {
- return EnrollmentStatus{
- Status: EnrollmentRejected,
- DeviceID: deviceID,
- Message: message,
- }, nil
- }
-
- if status == EnrollmentPending && deviceID != "" {
- return PollEnrollment(b, st, version)
- }
-
- return RegisterDevice(b, st, version)
-}
-
-// RegisterDevice POSTs /api/devices/register.
-func RegisterDevice(b Branding, st *AppState, version string) (EnrollmentStatus, error) {
- deviceID, _, _, _ := st.Snapshot()
- publicKey, proofErr := enrollmentPublicKey(st)
- if proofErr != nil {
- return EnrollmentStatus{}, fmt.Errorf("create enrollment identity: %w", proofErr)
- }
- hostname, _ := os.Hostname()
- if hostname == "" {
- hostname = "unknown"
- }
-
- payload := map[string]any{
- "device_id": deviceID,
- "uuid": st.GetMachineUUID(),
- "hostname": hostname,
- "platform": fmt.Sprintf("%s %s", runtime.GOOS, runtime.GOARCH),
- "version": version,
- "device_type": "os_agent",
- "public_key": publicKey,
- }
- if b.BundleID != "" {
- payload["bundle_id"] = b.BundleID
- }
- // A bundle credential is never embedded or sent here. An already-approved
- // device may authenticate its own refresh with its per-device bearer token
- // (set below), which prevents unnecessary token re-issuance.
- tags := []string{"support-agent"}
- if IsPortable() {
- tags = append(tags, "portable")
- } else {
- tags = append(tags, "installed")
- }
- payload["tags"] = strings.Join(tags, ",")
-
- var resp enrollmentResponse
- var code int
- var err error
- var url string
- for _, base := range CandidateAPIBases(b, st) {
- url = strings.TrimRight(base, "/") + "/devices/register"
- headers, proofErr := enrollmentProofHeaders(http.MethodPost, url, deviceID, st)
- if proofErr != nil {
- return EnrollmentStatus{}, proofErr
- }
- _, currentToken, _ := st.EnrollmentSnapshot()
- if currentToken != "" {
- headers.Set("Authorization", "Bearer "+currentToken)
- }
- // #region agent log
- debugLog("H1", "enrollment.go:RegisterDevice", "register request", map[string]any{
- "url": url, "device_id": deviceID, "sends_token": false,
- "bundle_id": b.BundleID, "use_https": b.UseHTTPS,
- })
- // #endregion
- code, err = apiJSONWithHeaders(http.MethodPost, url, payload, headers, &resp)
- if err == nil {
- st.RememberGoodEndpoints("", base)
- break
- }
- }
- if err != nil {
- return EnrollmentStatus{}, err
- }
- // #region agent log
- debugLog("H2", "enrollment.go:RegisterDevice", "register response", map[string]any{
- "http_code": code, "status": resp.Status, "device_id": resp.DeviceID,
- "token_len": len(strings.TrimSpace(resp.DeviceToken)), "message": resp.Message,
- })
- // #endregion
- result := EnrollmentStatus{
- Status: resp.Status,
- DeviceID: resp.DeviceID,
- DeviceToken: strings.TrimSpace(resp.DeviceToken),
- Message: resp.Message,
- }
- if result.DeviceID == "" {
- result.DeviceID = deviceID
- }
-
- if code != http.StatusOK && code != http.StatusAccepted {
- if code == http.StatusConflict && resp.Error == "identity_conflict" {
- newID := strings.TrimSpace(resp.SuggestedDeviceID)
- if newID == "" {
- newID = deriveDeviceIDWithSuffix(deviceID, "-2")
- }
- if err := st.SetDeviceID(newID); err != nil {
- return EnrollmentStatus{}, err
- }
- return RegisterDevice(b, st, version)
- }
- if result.Status == EnrollmentRejected {
- if err := st.SetEnrollment(EnrollmentRejected, result.DeviceID, "", result.Message); err != nil {
- return result, err
- }
- return result, nil
- }
- return result, fmt.Errorf("registration failed (HTTP %d)", code)
- }
-
- switch resp.Status {
- case EnrollmentApproved:
- if result.DeviceToken == "" {
- // Ordinary authenticated refreshes intentionally do not re-emit
- // the long-lived device credential. Retain the locally encrypted
- // token instead of treating a successful refresh as an error.
- st.mu.Lock()
- result.DeviceToken = strings.TrimSpace(st.DeviceToken)
- st.mu.Unlock()
- if result.DeviceToken == "" {
- return result, fmt.Errorf("registration approved without device_token")
- }
- }
- if err := st.SetEnrollment(EnrollmentApproved, result.DeviceID, result.DeviceToken, ""); err != nil {
- return result, err
- }
- case EnrollmentPending:
- if err := st.SetEnrollment(EnrollmentPending, result.DeviceID, "", resp.Message); err != nil {
- return result, err
- }
- case EnrollmentRejected:
- if err := st.SetEnrollment(EnrollmentRejected, result.DeviceID, "", resp.Message); err != nil {
- return result, err
- }
- default:
- return result, fmt.Errorf("unexpected enrollment status: %s", resp.Status)
- }
- return result, nil
-}
-
-// PollEnrollment GETs /api/devices/register/status.
-func PollEnrollment(b Branding, st *AppState, version string) (EnrollmentStatus, error) {
- deviceID, _, _, _ := st.Snapshot()
- var resp enrollmentResponse
- var code int
- var err error
- var requestURL string
- for _, base := range CandidateAPIBases(b, st) {
- requestURL = strings.TrimRight(base, "/") + "/devices/register/status?device_id=" + url.QueryEscape(deviceID)
- headers, proofErr := enrollmentProofHeaders(http.MethodGet, requestURL, deviceID, st)
- if proofErr != nil {
- return EnrollmentStatus{}, proofErr
- }
- _, currentToken, _ := st.EnrollmentSnapshot()
- if currentToken != "" {
- headers.Set("Authorization", "Bearer "+currentToken)
- }
- // #region agent log
- debugLog("H3", "enrollment.go:PollEnrollment", "poll request", map[string]any{"url": requestURL, "device_id": deviceID})
- // #endregion
- code, err = apiJSONWithHeaders(http.MethodGet, requestURL, nil, headers, &resp)
- if err == nil {
- st.RememberGoodEndpoints("", base)
- break
- }
- }
- if err != nil {
- return EnrollmentStatus{}, err
- }
- // #region agent log
- debugLog("H3", "enrollment.go:PollEnrollment", "poll response", map[string]any{
- "http_code": code, "status": resp.Status, "token_len": len(strings.TrimSpace(resp.DeviceToken)),
- "message": resp.Message,
- })
- // #endregion
- result := EnrollmentStatus{
- Status: resp.Status,
- DeviceID: resp.DeviceID,
- DeviceToken: strings.TrimSpace(resp.DeviceToken),
- Message: resp.Message,
- }
- if result.DeviceID == "" {
- result.DeviceID = deviceID
- }
-
- if code == http.StatusNotFound {
- st.mu.Lock()
- localStatus := st.EnrollmentStatus
- st.mu.Unlock()
- if localStatus == EnrollmentPending {
- // A pending request may have been pruned before a decision. It has
- // no active credential, so retrying registration is safe.
- return RegisterDevice(b, st, version)
- }
- result := EnrollmentStatus{
- Status: EnrollmentRejected,
- DeviceID: deviceID,
- Message: "device enrollment is no longer recognized by the server",
- }
- if err := st.SetEnrollment(EnrollmentRejected, result.DeviceID, "", result.Message); err != nil {
- return result, err
- }
- return result, nil
- }
- if code == http.StatusForbidden && resp.Status == EnrollmentRejected {
- result := EnrollmentStatus{
- Status: EnrollmentRejected,
- DeviceID: deviceID,
- Message: resp.Message,
- }
- if err := st.SetEnrollment(EnrollmentRejected, result.DeviceID, "", result.Message); err != nil {
- return result, err
- }
- return result, nil
- }
- if code != http.StatusOK {
- if result.Status == EnrollmentRejected {
- if err := st.SetEnrollment(EnrollmentRejected, result.DeviceID, "", result.Message); err != nil {
- return result, err
- }
- return result, nil
- }
- return result, fmt.Errorf("status poll failed (HTTP %d)", code)
- }
-
- switch resp.Status {
- case EnrollmentApproved:
- if result.DeviceToken == "" {
- st.mu.Lock()
- result.DeviceToken = strings.TrimSpace(st.DeviceToken)
- st.mu.Unlock()
- if result.DeviceToken == "" {
- return RegisterDevice(b, st, version)
- }
- }
- if err := st.SetEnrollment(EnrollmentApproved, result.DeviceID, result.DeviceToken, ""); err != nil {
- return result, err
- }
- case EnrollmentPending:
- _ = st.SetEnrollmentMessage(resp.Message)
- case EnrollmentRejected:
- if err := st.SetEnrollment(EnrollmentRejected, result.DeviceID, "", resp.Message); err != nil {
- return result, err
- }
- }
- return result, nil
-}
-
-// StartEnrollmentPoll runs until approved/rejected or ctx cancelled.
-func StartEnrollmentPoll(b Branding, st *AppState, version string, interval time.Duration, onUpdate func(EnrollmentStatus)) {
- go func() {
- ticker := time.NewTicker(interval)
- defer ticker.Stop()
- for range ticker.C {
- res, err := PollEnrollment(b, st, version)
- if err != nil {
- // #region agent log
- debugLog("H3", "enrollment.go:StartEnrollmentPoll", "poll error", map[string]any{"error": err.Error()})
- // #endregion
- continue
- }
- if onUpdate != nil {
- onUpdate(res)
- }
- if res.Status != EnrollmentPending {
- return
- }
- }
- }()
-}
-
-// StartEnrollmentRevalidation checks an already-approved device periodically
-// so a server-side revoke/disable stops the local transport without requiring
-// a restart. Network failures are intentionally retried rather than turning a
-// transient outage into an enrollment decision.
-func StartEnrollmentRevalidation(b Branding, st *AppState, version string, interval time.Duration, onUpdate func(EnrollmentStatus)) {
- if interval <= 0 {
- interval = time.Minute
- }
- go func() {
- ticker := time.NewTicker(interval)
- defer ticker.Stop()
- for range ticker.C {
- status, _, _ := st.EnrollmentSnapshot()
- if status != EnrollmentApproved {
- return
- }
- result, err := PollEnrollment(b, st, version)
- if err != nil {
- continue
- }
- if result.Status == EnrollmentApproved {
- continue
- }
- if onUpdate != nil {
- onUpdate(result)
- }
- return
- }
- }()
-}
diff --git a/betterdesk-support-agent/enrollment_proof.go b/betterdesk-support-agent/enrollment_proof.go
deleted file mode 100644
index 774fa4d0..00000000
--- a/betterdesk-support-agent/enrollment_proof.go
+++ /dev/null
@@ -1,148 +0,0 @@
-package main
-
-import (
- "bytes"
- "context"
- "crypto/ed25519"
- "crypto/rand"
- "crypto/sha256"
- "encoding/base64"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strings"
- "time"
-)
-
-const (
- enrollmentProofTimestampHeader = "X-BD-Enrollment-Timestamp"
- enrollmentProofNonceHeader = "X-BD-Enrollment-Nonce"
- enrollmentProofSignatureHeader = "X-BD-Enrollment-Signature"
-)
-
-// enrollmentIdentity deterministically derives an Ed25519 key from the
-// installation secret, which is encrypted in the local state file. This gives
-// an installation a stable proof-of-possession identity without placing a
-// second private-key file next to the binary.
-func enrollmentIdentity(st *AppState) (ed25519.PublicKey, ed25519.PrivateKey, error) {
- if st == nil {
- return nil, nil, fmt.Errorf("enrollment state is required")
- }
- st.mu.Lock()
- secret := st.InstallationSecret
- st.mu.Unlock()
- if secret == "" {
- return nil, nil, fmt.Errorf("installation secret is unavailable")
- }
- decoded, err := hex.DecodeString(secret)
- if err != nil {
- return nil, nil, fmt.Errorf("decode installation secret: %w", err)
- }
- seedMaterial := append([]byte("betterdesk-support-enrollment-ed25519-v1|"), decoded...)
- seed := sha256.Sum256(seedMaterial)
- privateKey := ed25519.NewKeyFromSeed(seed[:])
- return privateKey.Public().(ed25519.PublicKey), privateKey, nil
-}
-
-func enrollmentPublicKey(st *AppState) (string, error) {
- publicKey, _, err := enrollmentIdentity(st)
- if err != nil {
- return "", err
- }
- return base64.StdEncoding.EncodeToString(publicKey), nil
-}
-
-// enrollmentProofHeaders signs the method and path of an enrollment request.
-// The signature is intentionally scoped to this endpoint and includes a
-// timestamp and nonce so it cannot be reused as a management-channel proof.
-func enrollmentProofHeaders(method, rawURL, deviceID string, st *AppState) (http.Header, error) {
- if strings.TrimSpace(deviceID) == "" {
- return nil, fmt.Errorf("device id is required for enrollment proof")
- }
- publicKey, privateKey, err := enrollmentIdentity(st)
- if err != nil {
- return nil, err
- }
- parsed, err := url.Parse(rawURL)
- if err != nil || parsed.Path == "" {
- return nil, fmt.Errorf("parse enrollment URL: %w", err)
- }
- nonceBytes := make([]byte, 24)
- if _, err := rand.Read(nonceBytes); err != nil {
- return nil, fmt.Errorf("generate enrollment nonce: %w", err)
- }
- timestamp := time.Now().UTC().Format(time.RFC3339)
- nonce := base64.RawURLEncoding.EncodeToString(nonceBytes)
- canonicalPublicKey := base64.StdEncoding.EncodeToString(publicKey)
- payload := fmt.Sprintf(
- "bd-enrollment-v1\n%s\n%s\n%s\n%s\n%s\n%s",
- strings.ToUpper(strings.TrimSpace(method)),
- parsed.EscapedPath(),
- strings.TrimSpace(deviceID),
- canonicalPublicKey,
- timestamp,
- nonce,
- )
- signature := ed25519.Sign(privateKey, []byte(payload))
- headers := make(http.Header)
- headers.Set(enrollmentProofTimestampHeader, timestamp)
- headers.Set(enrollmentProofNonceHeader, nonce)
- headers.Set(enrollmentProofSignatureHeader, base64.StdEncoding.EncodeToString(signature))
-
- // A device may use the current token to migrate a legacy enrollment that
- // predates the public-key binding. It is sent in a header rather than a URL.
- if _, token, _ := st.EnrollmentSnapshot(); token != "" {
- headers.Set("Authorization", "Bearer "+token)
- }
- return headers, nil
-}
-
-// apiJSONWithHeaders is the enrollment-only form of apiJSON. It shares the
-// hardened HTTP client while allowing proof-of-possession request headers.
-func apiJSONWithHeaders(method, apiURL string, body any, headers http.Header, out any) (int, error) {
- if err := validateAPIEndpoint(apiURL); err != nil {
- return 0, err
- }
- var reader io.Reader
- if body != nil {
- data, err := json.Marshal(body)
- if err != nil {
- return 0, err
- }
- reader = bytes.NewReader(data)
- }
- ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
- defer cancel()
-
- req, err := http.NewRequestWithContext(ctx, method, apiURL, reader)
- if err != nil {
- return 0, err
- }
- if body != nil {
- req.Header.Set("Content-Type", "application/json")
- }
- for key, values := range headers {
- for _, value := range values {
- req.Header.Add(key, value)
- }
- }
-
- resp, err := apiHTTPClient(22 * time.Second).Do(req)
- if err != nil {
- return 0, err
- }
- defer resp.Body.Close()
- raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
- if err != nil {
- return resp.StatusCode, err
- }
- if out != nil && len(raw) > 0 {
- if err := json.Unmarshal(raw, out); err != nil {
- return resp.StatusCode, fmt.Errorf("invalid JSON: %w", err)
- }
- }
- return resp.StatusCode, nil
-}
diff --git a/betterdesk-support-agent/enrollment_proof_test.go b/betterdesk-support-agent/enrollment_proof_test.go
deleted file mode 100644
index 327e96f4..00000000
--- a/betterdesk-support-agent/enrollment_proof_test.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package main
-
-import (
- "crypto/ed25519"
- "encoding/base64"
- "fmt"
- "net/http"
- "net/http/httptest"
- "testing"
-)
-
-func proofTestState() *AppState {
- return &AppState{
- DeviceID: "BD-ABC123",
- InstallationSecret: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
- DeviceToken: "existing-device-token",
- }
-}
-
-func TestEnrollmentProofHeadersAreBoundToRequest(t *testing.T) {
- st := proofTestState()
- headers, err := enrollmentProofHeaders(http.MethodPost, "https://desk.example/api/devices/register", st.DeviceID, st)
- if err != nil {
- t.Fatal(err)
- }
- publicKey, _, err := enrollmentIdentity(st)
- if err != nil {
- t.Fatal(err)
- }
- timestamp := headers.Get(enrollmentProofTimestampHeader)
- nonce := headers.Get(enrollmentProofNonceHeader)
- signature, err := base64.StdEncoding.DecodeString(headers.Get(enrollmentProofSignatureHeader))
- if err != nil {
- t.Fatal(err)
- }
- payload := fmt.Sprintf(
- "bd-enrollment-v1\n%s\n%s\n%s\n%s\n%s\n%s",
- http.MethodPost,
- "/api/devices/register",
- st.DeviceID,
- base64.StdEncoding.EncodeToString(publicKey),
- timestamp,
- nonce,
- )
- if !ed25519.Verify(publicKey, []byte(payload), signature) {
- t.Fatal("enrollment proof did not verify")
- }
- if headers.Get("Authorization") != "Bearer existing-device-token" {
- t.Fatalf("unexpected authorization header: %q", headers.Get("Authorization"))
- }
-}
-
-func TestEnrollmentPublicKeyIsStablePerInstallation(t *testing.T) {
- st := proofTestState()
- first, err := enrollmentPublicKey(st)
- if err != nil {
- t.Fatal(err)
- }
- second, err := enrollmentPublicKey(st)
- if err != nil {
- t.Fatal(err)
- }
- if first != second {
- t.Fatalf("identity changed: %q != %q", first, second)
- }
-}
-
-func TestAPIJSONWithHeadersForwardsProofWithoutURLSecrets(t *testing.T) {
- st := proofTestState()
- var gotAuthorization string
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotAuthorization = r.Header.Get("Authorization")
- if r.URL.RawQuery != "" {
- t.Errorf("unexpected query data: %q", r.URL.RawQuery)
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"status":"approved"}`))
- }))
- defer server.Close()
-
- headers, err := enrollmentProofHeaders(http.MethodPost, server.URL+"/api/devices/register", st.DeviceID, st)
- if err != nil {
- t.Fatal(err)
- }
- var response struct {
- Status string `json:"status"`
- }
- code, err := apiJSONWithHeaders(http.MethodPost, server.URL+"/api/devices/register", map[string]string{"device_id": st.DeviceID}, headers, &response)
- if err != nil {
- t.Fatal(err)
- }
- if code != http.StatusOK || response.Status != "approved" {
- t.Fatalf("unexpected response code=%d payload=%+v", code, response)
- }
- if gotAuthorization != "Bearer existing-device-token" {
- t.Fatalf("authorization header not forwarded: %q", gotAuthorization)
- }
-}
diff --git a/betterdesk-support-agent/enrollment_test.go b/betterdesk-support-agent/enrollment_test.go
deleted file mode 100644
index c6976e0a..00000000
--- a/betterdesk-support-agent/enrollment_test.go
+++ /dev/null
@@ -1,130 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-)
-
-func enrollmentTestState(t *testing.T) *AppState {
- t.Helper()
- t.Setenv("BETTERDESK_AGENT_DATA_DIR", t.TempDir())
-
- st, err := LoadState()
- if err != nil {
- t.Fatal(err)
- }
- return st
-}
-
-func enrollmentTestBranding(serverURL string) Branding {
- return Branding{
- ServerAddress: serverURL,
- Server: &ServerBranding{
- Address: serverURL,
- APIURL: serverURL + "/api",
- },
- }
-}
-
-func TestPollEnrollmentPersistsRejectedServerState(t *testing.T) {
- st := enrollmentTestState(t)
- if err := st.SetEnrollment(EnrollmentPending, st.DeviceID, "", "Waiting for approval"); err != nil {
- t.Fatal(err)
- }
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet || r.URL.Path != "/api/devices/register/status" {
- t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
- }
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusForbidden)
- _ = json.NewEncoder(w).Encode(enrollmentResponse{
- Status: EnrollmentRejected,
- DeviceID: st.DeviceID,
- Message: "Device enrollment was rejected",
- })
- }))
- defer server.Close()
-
- res, err := PollEnrollment(enrollmentTestBranding(server.URL), st, "test")
- if err != nil {
- t.Fatal(err)
- }
- if res.Status != EnrollmentRejected {
- t.Fatalf("status = %q, want %q", res.Status, EnrollmentRejected)
- }
-
- status, token, message := st.EnrollmentSnapshot()
- if status != EnrollmentRejected || token != "" || message != "Device enrollment was rejected" {
- t.Fatalf("persisted enrollment = status=%q token=%q message=%q", status, token, message)
- }
-}
-
-func TestEnsureEnrolledDoesNotUseCachedTokenAfterRejection(t *testing.T) {
- st := enrollmentTestState(t)
- if err := st.SetEnrollment(EnrollmentApproved, st.DeviceID, "cached-device-token", ""); err != nil {
- t.Fatal(err)
- }
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost || r.URL.Path != "/api/devices/register" {
- t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
- }
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusForbidden)
- _ = json.NewEncoder(w).Encode(enrollmentResponse{
- Status: EnrollmentRejected,
- DeviceID: st.DeviceID,
- Message: "Device is banned",
- })
- }))
- defer server.Close()
-
- res, err := EnsureEnrolled(enrollmentTestBranding(server.URL), st, "test")
- if err != nil {
- t.Fatal(err)
- }
- if res.Status != EnrollmentRejected || res.Message != "Device is banned" {
- t.Fatalf("unexpected enrollment result: %+v", res)
- }
-
- status, token, _ := st.EnrollmentSnapshot()
- if status != EnrollmentRejected || token != "" {
- t.Fatalf("rejected enrollment retained a cached credential: status=%q token=%q", status, token)
- }
-}
-
-func TestPollEnrollmentKeepsExistingTokenOnOrdinaryApprovedRefresh(t *testing.T) {
- st := enrollmentTestState(t)
- if err := st.SetEnrollment(EnrollmentApproved, st.DeviceID, "existing-device-token", ""); err != nil {
- t.Fatal(err)
- }
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet || r.URL.Path != "/api/devices/register/status" {
- t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
- }
- if got := r.Header.Get("Authorization"); got != "Bearer existing-device-token" {
- t.Fatalf("Authorization = %q, want device bearer token", got)
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(enrollmentResponse{
- Status: EnrollmentApproved,
- DeviceID: st.DeviceID,
- })
- }))
- defer server.Close()
-
- result, err := PollEnrollment(enrollmentTestBranding(server.URL), st, "test")
- if err != nil {
- t.Fatal(err)
- }
- if result.DeviceToken != "existing-device-token" {
- t.Fatalf("token = %q, want retained local token", result.DeviceToken)
- }
- status, token, _ := st.EnrollmentSnapshot()
- if status != EnrollmentApproved || token != "existing-device-token" {
- t.Fatalf("state = status=%q token=%q", status, token)
- }
-}
diff --git a/betterdesk-support-agent/exec_other.go b/betterdesk-support-agent/exec_other.go
deleted file mode 100644
index 6ede1a47..00000000
--- a/betterdesk-support-agent/exec_other.go
+++ /dev/null
@@ -1,7 +0,0 @@
-//go:build !windows
-
-package main
-
-import "os/exec"
-
-func hideConsole(cmd *exec.Cmd) {}
diff --git a/betterdesk-support-agent/exec_windows.go b/betterdesk-support-agent/exec_windows.go
deleted file mode 100644
index 538584eb..00000000
--- a/betterdesk-support-agent/exec_windows.go
+++ /dev/null
@@ -1,21 +0,0 @@
-//go:build windows
-
-package main
-
-import (
- "os/exec"
- "syscall"
-)
-
-const createNoWindow = 0x08000000
-
-func hideConsole(cmd *exec.Cmd) {
- if cmd == nil {
- return
- }
- if cmd.SysProcAttr == nil {
- cmd.SysProcAttr = &syscall.SysProcAttr{}
- }
- cmd.SysProcAttr.HideWindow = true
- cmd.SysProcAttr.CreationFlags |= createNoWindow
-}
diff --git a/betterdesk-support-agent/fingerprint.go b/betterdesk-support-agent/fingerprint.go
deleted file mode 100644
index 8278947b..00000000
--- a/betterdesk-support-agent/fingerprint.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package main
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "os"
- "runtime"
- "strings"
-)
-
-// machineFingerprint returns a stable hardware-oriented identifier for this
-// machine. It is sent to the server as the enrollment "uuid" anchor and is
-// distinct from the per-installation secret mixed into device_id.
-func machineFingerprint() string {
- parts := []string{runtime.GOOS, runtime.GOARCH}
- if id := platformMachineID(); id != "" {
- parts = append(parts, id)
- }
- if serial := platformBoardSerial(); serial != "" {
- parts = append(parts, serial)
- }
- if len(parts) <= 2 {
- if h, err := os.Hostname(); err == nil && h != "" {
- parts = append(parts, h)
- }
- }
- if len(parts) <= 2 {
- return ""
- }
- sum := sha256.Sum256([]byte(strings.Join(parts, "|")))
- return hex.EncodeToString(sum[:16])
-}
-
-// legacyMachineSeed returns the pre-v2 machine seed used for uuid on older
-// support-agent builds. Kept for migration of enrolled devices.
-func legacyMachineSeed() string {
- switch runtime.GOOS {
- case "linux":
- for _, p := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} {
- if b, err := os.ReadFile(p); err == nil {
- if id := strings.TrimSpace(string(b)); id != "" {
- return id
- }
- }
- }
- case "windows":
- if v := os.Getenv("COMPUTERNAME"); v != "" {
- return v
- }
- case "darwin":
- if h, err := os.Hostname(); err == nil {
- return h
- }
- }
- if h, err := os.Hostname(); err == nil {
- return h
- }
- return ""
-}
diff --git a/betterdesk-support-agent/fingerprint_darwin.go b/betterdesk-support-agent/fingerprint_darwin.go
deleted file mode 100644
index b1c3bd40..00000000
--- a/betterdesk-support-agent/fingerprint_darwin.go
+++ /dev/null
@@ -1,43 +0,0 @@
-//go:build darwin
-
-package main
-
-import (
- "os/exec"
- "strings"
-)
-
-func platformMachineID() string {
- out, err := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice").Output()
- if err != nil {
- return ""
- }
- for _, line := range strings.Split(string(out), "\n") {
- line = strings.TrimSpace(line)
- if strings.Contains(line, "IOPlatformUUID") {
- if i := strings.Index(line, `"`); i >= 0 {
- rest := line[i+1:]
- if j := strings.Index(rest, `"`); j >= 0 {
- return strings.TrimSpace(rest[:j])
- }
- }
- }
- }
- return ""
-}
-
-func platformBoardSerial() string {
- out, err := exec.Command("system_profiler", "SPHardwareDataType").Output()
- if err != nil {
- return ""
- }
- for _, line := range strings.Split(string(out), "\n") {
- line = strings.TrimSpace(line)
- if strings.HasPrefix(line, "Serial Number") {
- if i := strings.Index(line, ":"); i >= 0 {
- return strings.TrimSpace(line[i+1:])
- }
- }
- }
- return ""
-}
diff --git a/betterdesk-support-agent/fingerprint_linux.go b/betterdesk-support-agent/fingerprint_linux.go
deleted file mode 100644
index 0068a485..00000000
--- a/betterdesk-support-agent/fingerprint_linux.go
+++ /dev/null
@@ -1,41 +0,0 @@
-//go:build linux
-
-package main
-
-import (
- "os"
- "strings"
-)
-
-func platformMachineID() string {
- for _, p := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} {
- if b, err := os.ReadFile(p); err == nil {
- if id := strings.TrimSpace(string(b)); id != "" {
- return id
- }
- }
- }
- return ""
-}
-
-func platformBoardSerial() string {
- for _, p := range []string{
- "/sys/class/dmi/id/board_serial",
- "/sys/class/dmi/id/product_uuid",
- } {
- if b, err := os.ReadFile(p); err == nil {
- if s := strings.TrimSpace(string(b)); s != "" && !isPlaceholderSerial(s) {
- return s
- }
- }
- }
- return ""
-}
-
-func isPlaceholderSerial(s string) bool {
- switch strings.ToLower(s) {
- case "", "none", "not specified", "to be filled by o.e.m.", "default string", "0123456789":
- return true
- }
- return false
-}
diff --git a/betterdesk-support-agent/fingerprint_other.go b/betterdesk-support-agent/fingerprint_other.go
deleted file mode 100644
index d0789aad..00000000
--- a/betterdesk-support-agent/fingerprint_other.go
+++ /dev/null
@@ -1,16 +0,0 @@
-//go:build !linux && !windows && !darwin
-
-package main
-
-import "os"
-
-func platformMachineID() string {
- if h, err := os.Hostname(); err == nil {
- return h
- }
- return ""
-}
-
-func platformBoardSerial() string {
- return ""
-}
diff --git a/betterdesk-support-agent/fingerprint_test.go b/betterdesk-support-agent/fingerprint_test.go
deleted file mode 100644
index fe16adc2..00000000
--- a/betterdesk-support-agent/fingerprint_test.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package main
-
-import (
- "encoding/hex"
- "strings"
- "testing"
-)
-
-func TestGenerateDeviceIDUniquePerInstallation(t *testing.T) {
- secA := hex.EncodeToString([]byte("installation-secret-a-0123456789ab"))
- secB := hex.EncodeToString([]byte("installation-secret-b-0123456789ab"))
- idA := generateDeviceID(secA)
- idB := generateDeviceID(secB)
- if idA == idB {
- t.Fatalf("expected different IDs for different installation secrets, got %q", idA)
- }
- if !strings.HasPrefix(idA, "BD-") || !strings.HasPrefix(idB, "BD-") {
- t.Fatalf("expected BD- prefix, got %q and %q", idA, idB)
- }
-}
-
-func TestGenerateDeviceIDStable(t *testing.T) {
- sec := hex.EncodeToString([]byte("stable-installation-secret-value"))
- a := generateDeviceID(sec)
- b := generateDeviceID(sec)
- if a != b {
- t.Fatalf("expected stable ID, got %q vs %q", a, b)
- }
-}
-
-func TestDeriveDeviceIDWithSuffix(t *testing.T) {
- got := deriveDeviceIDWithSuffix("BD-ABC12", "-2")
- if got != "BD-ABC12-2" {
- t.Fatalf("got %q", got)
- }
-}
-
-func TestMachineFingerprintNonEmpty(t *testing.T) {
- fp := machineFingerprint()
- if fp == "" {
- t.Skip("no fingerprint sources on this test host")
- }
- if len(fp) != 32 {
- t.Fatalf("expected 16-byte hex fingerprint, got len %d (%q)", len(fp), fp)
- }
-}
diff --git a/betterdesk-support-agent/fingerprint_windows.go b/betterdesk-support-agent/fingerprint_windows.go
deleted file mode 100644
index 7a5ad1e7..00000000
--- a/betterdesk-support-agent/fingerprint_windows.go
+++ /dev/null
@@ -1,30 +0,0 @@
-//go:build windows
-
-package main
-
-import (
- "os"
- "strings"
-
- "golang.org/x/sys/windows/registry"
-)
-
-func platformMachineID() string {
- k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, registry.QUERY_VALUE)
- if err != nil {
- return ""
- }
- defer k.Close()
- val, _, err := k.GetStringValue("MachineGuid")
- if err != nil {
- return ""
- }
- return strings.TrimSpace(val)
-}
-
-func platformBoardSerial() string {
- if v := os.Getenv("COMPUTERNAME"); v != "" {
- return strings.TrimSpace(v)
- }
- return ""
-}
diff --git a/betterdesk-support-agent/frontend/ui/app.js b/betterdesk-support-agent/frontend/ui/app.js
deleted file mode 100644
index a181ee47..00000000
--- a/betterdesk-support-agent/frontend/ui/app.js
+++ /dev/null
@@ -1,354 +0,0 @@
-/* global window */
-(function () {
- const go = () => window.go && window.go.main && window.go.main.AppService;
- let snap = null;
- let showPw = true;
-
- function $(id) { return document.getElementById(id); }
-
- function toast(msg) {
- const el = $('toast');
- el.textContent = msg;
- el.hidden = false;
- clearTimeout(toast._t);
- toast._t = setTimeout(() => { el.hidden = true; }, 2200);
- }
-
- function applyI18n(strings) {
- document.querySelectorAll('[data-i18n]').forEach((el) => {
- const k = el.getAttribute('data-i18n');
- if (strings && strings[k]) el.textContent = strings[k];
- });
- }
-
- function setLogo(dataUrl) {
- const top = $('logo-top');
- const topFb = $('logo-top-fallback');
- const hero = $('logo-hero');
- const art = $('hero-art');
- if (dataUrl) {
- top.src = dataUrl;
- top.hidden = false;
- topFb.hidden = true;
- hero.src = dataUrl;
- hero.hidden = false;
- art.hidden = true;
- } else {
- top.hidden = true;
- topFb.hidden = false;
- hero.hidden = true;
- art.hidden = false;
- }
- }
-
- function fillCredentials() {
- if (!snap) return;
- const id = snap.device_id_fmt || snap.device_id || '—';
- const pw = showPw ? (snap.password || '—') : (snap.password_masked || '—');
- $('device-id').textContent = id;
- $('password').textContent = pw;
- $('share-device-id').textContent = id;
- $('share-password').textContent = pw;
- const show = !!snap.show_password;
- $('password-card').hidden = !show;
- $('share-password-card').hidden = !show;
- }
-
- function setSessionView(active) {
- $('view-idle').hidden = !!active;
- $('view-session').hidden = !active;
- if (active) {
- const label = (snap.strings && snap.strings.ongoing_session) || 'Ongoing session';
- const withLabel = (snap.strings && snap.strings.session_with) || 'Session with';
- const op = snap.session_operator || '';
- $('session-detail').textContent = op
- ? (withLabel + ' ' + op)
- : ((snap.strings && snap.strings.session_active) || label);
- }
- }
-
- function applySnapshot(s) {
- snap = s;
- if (!s) return;
- document.documentElement.style.setProperty('--primary', s.primary_color || '#2563eb');
- document.documentElement.style.setProperty('--accent', s.accent_color || '#e0f2fe');
- document.documentElement.style.setProperty('--surface', s.surface_color || '#f3f4f6');
- document.documentElement.style.setProperty('--bg', s.background_color || '#ffffff');
- document.documentElement.style.setProperty('--text', s.text_color || '#1f2937');
- document.documentElement.style.setProperty('--muted', s.text_muted_color || '#6b7280');
- document.documentElement.style.setProperty('--header-text', s.header_text_color || '#1f2937');
- document.documentElement.style.setProperty('--ready', s.status_ready_color || '#22c55e');
-
- $('product').textContent = s.product_name || 'BetterDesk Support';
- $('tagline').textContent = s.tagline || '';
- document.title = (s.product_name || 'BetterDesk') + ' — ' + ((s.strings && s.strings.window_title) || 'Support');
-
- $('status-text').textContent = s.status_text || '';
- const dot = $('status-dot');
- dot.className = 'dot ' + (s.status_kind || 'ready');
-
- const contact = [s.support_email, s.support_phone, s.contact_url].filter(Boolean);
- const contactEl = $('contact');
- if (contact.length) {
- contactEl.hidden = false;
- contactEl.textContent = contact.join(' • ');
- contactEl.title = contactEl.textContent;
- } else {
- contactEl.hidden = true;
- contactEl.textContent = '';
- }
-
- setLogo(s.logo_data_url || '');
- fillCredentials();
- applyI18n(s.strings || {});
- setSessionView(!!s.session_active);
- }
-
- function closeModal() { $('modal').hidden = true; }
-
- function openModal(title, bodyEl, actions, opts) {
- opts = opts || {};
- $('modal-title').textContent = title;
- const body = $('modal-body');
- body.innerHTML = '';
- body.appendChild(bodyEl);
- const act = $('modal-actions');
- act.innerHTML = '';
- (actions || []).forEach((a) => {
- const b = document.createElement('button');
- b.type = 'button';
- b.className = 'btn ' + (a.primary ? 'primary' : (a.danger ? 'danger' : 'secondary'));
- b.textContent = a.label;
- if (a.disabled) b.disabled = true;
- if (a.id) b.id = a.id;
- b.onclick = () => { a.onClick(b); };
- act.appendChild(b);
- });
- $('modal').hidden = false;
- if (opts.focusId) {
- const el = document.getElementById(opts.focusId);
- if (el) el.focus();
- }
- }
-
- function openShareModal() {
- fillCredentials();
- applyI18n((snap && snap.strings) || {});
- $('share-modal').hidden = false;
- }
-
- function closeShareModal() { $('share-modal').hidden = true; }
-
- async function copyCredentials() {
- if (!snap) return;
- const id = snap.device_id || '';
- const pw = snap.password || '';
- const text = snap.show_password ? (id + '\n' + pw) : id;
- await navigator.clipboard.writeText(text);
- toast((snap.strings && snap.strings.copied) || 'Copied');
- }
-
- async function regenPassword() {
- try {
- await go().RegeneratePassword();
- await refresh();
- toast((snap.strings && snap.strings.password_regenerated) || 'New password generated');
- } catch (e) {
- toast(String(e));
- }
- }
-
- async function refresh() {
- const api = go();
- if (!api) return;
- applySnapshot(await api.GetSnapshot());
- }
-
- function openConsent(payload) {
- const strings = (snap && snap.strings) || {};
- const wrap = document.createElement('div');
- const grid = document.createElement('div');
- grid.className = 'consent-grid';
-
- function row(label, value) {
- const r = document.createElement('div');
- r.className = 'consent-row';
- const l = document.createElement('div');
- l.className = 'consent-label';
- l.textContent = label;
- const v = document.createElement('div');
- v.className = 'consent-value';
- v.textContent = value;
- r.appendChild(l);
- r.appendChild(v);
- grid.appendChild(r);
- }
-
- row(strings.consent_display_name || 'Display name', (payload && payload.operator) || '—');
- row(strings.consent_session || 'Session', (payload && payload.session_id) || '—');
- wrap.appendChild(grid);
-
- const prompt = document.createElement('p');
- prompt.className = 'section-hint';
- prompt.style.textAlign = 'left';
- prompt.textContent = (payload && payload.prompt) || strings.consent_prompt || 'Allow remote access?';
- wrap.appendChild(prompt);
-
- const checkLabel = document.createElement('label');
- checkLabel.className = 'consent-check';
- const check = document.createElement('input');
- check.type = 'checkbox';
- check.id = 'consent-ack';
- const checkText = document.createElement('span');
- checkText.textContent = strings.consent_ack || 'I have read the expert information.';
- checkLabel.appendChild(check);
- checkLabel.appendChild(checkText);
- wrap.appendChild(checkLabel);
-
- openModal(strings.consent_title || 'Remote access request', wrap, [
- {
- label: strings.consent_deny || 'Reject',
- onClick: () => { go().AnswerConsent(false); closeModal(); }
- },
- {
- id: 'consent-continue',
- label: strings.consent_accept || 'Approve',
- primary: true,
- disabled: true,
- onClick: () => { go().AnswerConsent(true); closeModal(); }
- }
- ]);
-
- check.addEventListener('change', () => {
- const btn = document.getElementById('consent-continue');
- if (btn) btn.disabled = !check.checked;
- });
- }
-
- function bind() {
- $('modal-close').onclick = closeModal;
- $('share-close').onclick = closeShareModal;
- $('copy-creds').onclick = copyCredentials;
- $('share-copy').onclick = copyCredentials;
- $('btn-regen').onclick = regenPassword;
- $('share-regen').onclick = regenPassword;
- $('btn-disconnect').onclick = () => go().DisconnectSession();
- $('btn-share-id').onclick = openShareModal;
-
- $('btn-help').onclick = () => {
- const ta = document.createElement('textarea');
- ta.rows = 4;
- ta.placeholder = (snap.strings && snap.strings.help_message) || '';
- openModal((snap.strings && snap.strings.request_help) || 'Help', ta, [
- { label: (snap.strings && snap.strings.cancel) || 'Cancel', onClick: closeModal },
- {
- label: (snap.strings && snap.strings.send) || 'Send', primary: true,
- onClick: async () => {
- try {
- await go().SendHelp(ta.value || '');
- closeModal();
- toast((snap.strings && snap.strings.help_sent) || 'Sent');
- } catch (e) {
- toast((snap.strings && snap.strings.help_failed) || String(e));
- }
- }
- }
- ]);
- };
-
- $('btn-chat').onclick = async () => {
- const wrap = document.createElement('div');
- const log = document.createElement('div');
- log.className = 'chat-log';
- const hist = await go().GetChatHistory();
- log.textContent = (hist || []).join('\n');
- const input = document.createElement('input');
- input.placeholder = (snap.strings && snap.strings.chat_placeholder) || '…';
- wrap.appendChild(log);
- wrap.appendChild(input);
- openModal((snap.strings && snap.strings.chat_with_support) || 'Chat', wrap, [
- { label: (snap.strings && snap.strings.close) || 'Close', onClick: closeModal },
- {
- label: (snap.strings && snap.strings.send) || 'Send', primary: true,
- onClick: async () => {
- await go().SendChat(input.value || '');
- input.value = '';
- const h = await go().GetChatHistory();
- log.textContent = (h || []).join('\n');
- }
- }
- ]);
- };
-
- $('btn-settings').onclick = () => {
- const wrap = document.createElement('div');
- wrap.style.display = 'grid';
- wrap.style.gap = '10px';
- const mode = document.createElement('select');
- (snap.mode_options || []).forEach((opt) => {
- const o = document.createElement('option');
- o.value = opt; o.textContent = opt;
- if (opt === (snap.strings && snap.strings['mode_' + snap.access_mode]) ||
- (snap.access_mode === 'supervised' && opt.indexOf('uperv') >= 0) ||
- (snap.access_mode === 'unattended' && opt.indexOf('nattend') >= 0) ||
- (snap.access_mode === 'disabled' && opt.indexOf('isabl') >= 0)) {
- o.selected = true;
- }
- mode.appendChild(o);
- });
- const custom = document.createElement('input');
- custom.type = 'password';
- custom.placeholder = (snap.strings && snap.strings.set_custom) || 'Custom password';
- wrap.appendChild(mode);
- wrap.appendChild(custom);
- openModal((snap.strings && snap.strings.settings) || 'Settings', wrap, [
- {
- label: (snap.strings && snap.strings.test_connection) || 'Test',
- onClick: async () => {
- const r = await go().TestConnection();
- alert([r.title, r.gateway, r.api, r.enrollment].join('\n'));
- }
- },
- {
- label: (snap.strings && snap.strings.regenerate) || 'Regenerate',
- onClick: async () => { await go().RegeneratePassword(); await refresh(); }
- },
- {
- label: (snap.strings && snap.strings.quit) || 'Quit',
- danger: true,
- onClick: () => go().Quit()
- },
- { label: (snap.strings && snap.strings.cancel) || 'Cancel', onClick: closeModal },
- {
- label: (snap.strings && snap.strings.save) || 'Save', primary: true,
- onClick: async () => {
- await go().SetAccessMode(mode.value);
- if (custom.value) await go().SetCustomPassword(custom.value);
- closeModal();
- await refresh();
- }
- }
- ]);
- };
- }
-
- function bindEvents() {
- if (!window.runtime || !window.runtime.EventsOn) {
- setTimeout(bindEvents, 50);
- return;
- }
- window.runtime.EventsOn('snapshot', applySnapshot);
- window.runtime.EventsOn('toast', toast);
- window.runtime.EventsOn('chat', () => { /* chat modal refreshes on send */ });
- window.runtime.EventsOn('open-help', () => { $('btn-help').click(); });
- window.runtime.EventsOn('consent', (payload) => { openConsent(payload || {}); });
- window.runtime.EventsOn('session', () => refresh());
- }
-
- document.addEventListener('DOMContentLoaded', async () => {
- bind();
- bindEvents();
- for (let i = 0; i < 40 && !go(); i++) await new Promise((r) => setTimeout(r, 50));
- await refresh();
- });
-})();
diff --git a/betterdesk-support-agent/frontend/ui/index.html b/betterdesk-support-agent/frontend/ui/index.html
deleted file mode 100644
index 74299206..00000000
--- a/betterdesk-support-agent/frontend/ui/index.html
+++ /dev/null
@@ -1,171 +0,0 @@
-
-
-
-
-
- BetterDesk Support
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Get support
- Request help or chat with support. You can also share your ID and password below.
-
-
- Request help
-
-
- Chat with support
-
-
-
-
-
- Or share your ID
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Ongoing session
-
-
Close session
-
-
-
-
- Share ID and password
-
-
-
-
-
-
-
-
-
-
-
-
Share ID and password
- ×
-
-
Share these credentials so a supporter can connect to your device.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/betterdesk-support-agent/frontend/ui/styles.css b/betterdesk-support-agent/frontend/ui/styles.css
deleted file mode 100644
index 81edab7f..00000000
--- a/betterdesk-support-agent/frontend/ui/styles.css
+++ /dev/null
@@ -1,450 +0,0 @@
-:root {
- --primary: #2563eb;
- --accent: #e0f2fe;
- --surface: #f3f4f6;
- --bg: #ffffff;
- --text: #1f2937;
- --muted: #6b7280;
- --header-text: #1f2937;
- --ready: #22c55e;
- --pending: #f59e0b;
- --error: #ef4444;
- --radius: 10px;
- --border: color-mix(in srgb, var(--text) 12%, transparent);
- font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
-}
-
-* { box-sizing: border-box; }
-html, body {
- margin: 0;
- height: 100%;
- background: var(--bg);
- color: var(--text);
- user-select: none;
-}
-button, input, select, textarea { font: inherit; }
-#app {
- min-height: 100%;
- display: flex;
- flex-direction: column;
- background: var(--bg);
-}
-
-/* ---- Top bar ---- */
-.topbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 12px 16px 8px;
-}
-.topbar-brand {
- display: flex;
- align-items: center;
- gap: 10px;
- min-width: 0;
-}
-.logo-top {
- width: 28px;
- height: 28px;
- object-fit: contain;
- border-radius: 6px;
- flex-shrink: 0;
-}
-.logo-top-fallback {
- width: 28px;
- height: 28px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- color: var(--primary);
- flex-shrink: 0;
-}
-.topbar-text { min-width: 0; }
-.product {
- font-size: .95rem;
- font-weight: 650;
- color: var(--header-text);
- letter-spacing: -.01em;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.tagline {
- font-size: .72rem;
- color: var(--muted);
- margin-top: 1px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-/* ---- Views ---- */
-.view {
- flex: 1;
- padding: 4px 20px 16px;
- display: flex;
- flex-direction: column;
- gap: 14px;
-}
-.view[hidden] { display: none !important; }
-
-.hero {
- background: var(--accent);
- border-radius: 12px;
- min-height: 112px;
- display: flex;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- padding: 12px 16px;
-}
-.hero-art, .hero-session {
- width: 100%;
- max-width: 280px;
- color: var(--primary);
-}
-.hero-art svg, .hero-session svg { width: 100%; height: auto; display: block; }
-.logo-hero {
- max-width: 160px;
- max-height: 88px;
- object-fit: contain;
-}
-
-.section-title {
- margin: 4px 0 0;
- font-size: 1.35rem;
- font-weight: 700;
- letter-spacing: -.02em;
- text-align: center;
- color: var(--text);
-}
-.section-hint {
- margin: 0;
- font-size: .86rem;
- line-height: 1.45;
- color: var(--muted);
- text-align: center;
-}
-
-.support-section {
- display: flex;
- flex-direction: column;
- gap: 10px;
- align-items: stretch;
-}
-.cta-row {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 10px;
- margin-top: 4px;
-}
-
-.divider {
- display: flex;
- align-items: center;
- gap: 12px;
- color: var(--muted);
- font-size: .78rem;
- margin: 2px 0;
-}
-.divider::before,
-.divider::after {
- content: "";
- flex: 1;
- height: 1px;
- background: var(--border);
-}
-
-/* ---- Credentials card ---- */
-.cred-card {
- display: grid;
- grid-template-columns: 1fr 1fr auto;
- gap: 12px;
- align-items: center;
- background: var(--surface);
- border-radius: var(--radius);
- padding: 14px 12px 14px 16px;
-}
-.cred-col { min-width: 0; }
-.card-label {
- font-size: .72rem;
- color: var(--muted);
- margin-bottom: 4px;
-}
-.card-value {
- font-size: 1.05rem;
- font-weight: 700;
- word-break: break-all;
- user-select: text;
- color: var(--text);
-}
-.mono {
- font-family: ui-monospace, Consolas, "Cascadia Mono", monospace;
- letter-spacing: .06em;
-}
-.cred-actions {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-#password-card[hidden],
-#share-password-card[hidden] { display: none !important; }
-.cred-card:has(#password-card[hidden]) {
- grid-template-columns: 1fr auto;
-}
-
-/* ---- Buttons / icons ---- */
-.btn {
- border-radius: 999px;
- min-height: 40px;
- padding: 8px 14px;
- font-size: .9rem;
- font-weight: 600;
- cursor: pointer;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- transition: filter .15s ease, transform .15s ease, background .15s ease;
- border: 1px solid transparent;
-}
-.btn:hover { filter: brightness(1.05); }
-.btn:active { transform: scale(.985); }
-.btn:disabled {
- opacity: .45;
- cursor: not-allowed;
- filter: none;
- transform: none;
-}
-.btn.primary {
- background: var(--primary);
- color: #fff;
- border-color: var(--primary);
-}
-.btn.secondary {
- background: transparent;
- color: var(--primary);
- border-color: var(--primary);
-}
-.btn.outline {
- background: var(--bg);
- color: var(--text);
- border-color: color-mix(in srgb, var(--text) 28%, transparent);
-}
-.btn.outline.wide { width: 100%; max-width: 280px; align-self: center; margin-top: 8px; }
-.btn.danger {
- background: var(--error);
- color: #fff;
- border-radius: 8px;
- min-height: 36px;
- padding: 6px 12px;
- font-size: .85rem;
-}
-.icon-btn {
- background: transparent;
- border: 0;
- color: var(--muted);
- cursor: pointer;
- padding: 6px;
- border-radius: 8px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
-}
-.icon-btn:hover {
- background: color-mix(in srgb, var(--text) 6%, transparent);
- color: var(--text);
-}
-.text-link {
- align-self: center;
- margin-top: 10px;
- background: none;
- border: 0;
- color: var(--primary);
- font-size: .88rem;
- font-weight: 600;
- cursor: pointer;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 6px;
-}
-.text-link:hover { text-decoration: underline; }
-
-.session-view {
- align-items: stretch;
- text-align: center;
-}
-
-/* ---- Status footer ---- */
-.status-footer {
- display: flex;
- align-items: center;
- gap: 10px;
- min-height: 44px;
- padding: 10px 16px 14px;
- border-top: 1px solid var(--border);
- margin-top: auto;
-}
-.dot {
- width: 9px;
- height: 9px;
- border-radius: 50%;
- background: var(--ready);
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--ready) 22%, transparent);
- flex-shrink: 0;
-}
-.dot.pending { background: var(--pending); box-shadow: 0 0 0 3px color-mix(in srgb, var(--pending) 25%, transparent); }
-.dot.error { background: var(--error); box-shadow: 0 0 0 3px color-mix(in srgb, var(--error) 25%, transparent); }
-.dot.connected { background: var(--ready); }
-.status-text {
- flex: 1;
- min-width: 0;
- font-size: .8rem;
- color: var(--text);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.contact {
- font-size: .7rem;
- color: var(--muted);
- max-width: 36%;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-/* ---- Modals ---- */
-.modal {
- position: fixed;
- inset: 0;
- background: rgba(15, 23, 42, .45);
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 20px;
- z-index: 40;
-}
-.modal[hidden] { display: none !important; }
-.modal-card {
- width: min(400px, 100%);
- background: var(--bg);
- color: var(--text);
- border-radius: 12px;
- padding: 16px 18px 18px;
- box-shadow: 0 16px 40px rgba(0, 0, 0, .18);
- border: 1px solid var(--border);
-}
-.modal-head {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 8px;
- margin-bottom: 12px;
-}
-.modal-card h2 {
- margin: 0;
- font-size: 1.05rem;
- font-weight: 700;
-}
-.modal-close {
- font-size: 1.35rem;
- line-height: 1;
- color: var(--muted);
-}
-.modal-card textarea,
-.modal-card select,
-.modal-card input {
- width: 100%;
- background: var(--surface);
- color: var(--text);
- border: 1px solid var(--border);
- border-radius: 8px;
- padding: 10px;
-}
-.modal-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- justify-content: flex-end;
- margin-top: 14px;
-}
-.modal-actions .btn {
- border-radius: 8px;
- min-height: 36px;
- font-size: .85rem;
-}
-.share-card .cred-card { margin-top: 4px; }
-
-/* Consent / expert info */
-.consent-grid {
- display: flex;
- flex-direction: column;
- gap: 10px;
- margin: 4px 0 12px;
-}
-.consent-row {
- display: flex;
- justify-content: space-between;
- gap: 12px;
- align-items: flex-start;
- padding-bottom: 8px;
- border-bottom: 1px solid var(--border);
-}
-.consent-row:last-child { border-bottom: 0; padding-bottom: 0; }
-.consent-label {
- font-size: .78rem;
- color: var(--muted);
-}
-.consent-value {
- font-size: .88rem;
- font-weight: 650;
- text-align: right;
- word-break: break-word;
-}
-.consent-check {
- display: flex;
- align-items: flex-start;
- gap: 8px;
- font-size: .82rem;
- color: var(--text);
- margin: 8px 0 4px;
-}
-.consent-check input { width: auto; margin-top: 2px; }
-
-.chat-log {
- max-height: 220px;
- overflow: auto;
- background: var(--surface);
- border-radius: 8px;
- padding: 10px;
- font-size: .85rem;
- margin-bottom: 10px;
- white-space: pre-wrap;
-}
-
-.toast {
- position: fixed;
- left: 50%;
- bottom: 56px;
- transform: translateX(-50%);
- background: #111827;
- color: #fff;
- padding: 10px 14px;
- border-radius: 999px;
- z-index: 50;
- font-size: .85rem;
- box-shadow: 0 8px 24px rgba(0, 0, 0, .2);
-}
-.toast[hidden] { display: none !important; }
-
-@media (max-width: 380px) {
- .cta-row { grid-template-columns: 1fr; }
- .cred-card {
- grid-template-columns: 1fr auto;
- grid-template-rows: auto auto;
- }
- .cred-actions { flex-direction: row; }
-}
diff --git a/betterdesk-support-agent/go.mod b/betterdesk-support-agent/go.mod
deleted file mode 100644
index 55a4257f..00000000
--- a/betterdesk-support-agent/go.mod
+++ /dev/null
@@ -1,82 +0,0 @@
-module github.com/unitronix/betterdesk-support-agent
-
-go 1.25.0
-
-require (
- fyne.io/fyne/v2 v2.7.3
- fyne.io/systray v1.12.0
- github.com/fyne-io/image v0.1.1
- github.com/unitronix/betterdesk-agent v0.0.0-00010101000000-000000000000
- github.com/unitronix/betterdesk-server v0.0.0-00010101000000-000000000000
- github.com/wailsapp/wails/v2 v2.13.0
- golang.org/x/crypto v0.54.0
- golang.org/x/sys v0.47.0
- google.golang.org/protobuf v1.36.11
-)
-
-require (
- git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
- github.com/BurntSushi/toml v1.5.0 // indirect
- github.com/bep/debounce v1.2.1 // indirect
- github.com/coder/websocket v1.8.15 // indirect
- github.com/creack/pty v1.1.24 // indirect
- github.com/davecgh/go-spew v1.1.1 // indirect
- github.com/fredbi/uri v1.1.1 // indirect
- github.com/fsnotify/fsnotify v1.9.0 // indirect
- github.com/fyne-io/gl-js v0.2.0 // indirect
- github.com/fyne-io/glfw-js v0.3.0 // indirect
- github.com/fyne-io/oksvg v0.2.0 // indirect
- github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
- github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
- github.com/go-ole/go-ole v1.3.0 // indirect
- github.com/go-text/render v0.2.0 // indirect
- github.com/go-text/typesetting v0.3.3 // indirect
- github.com/godbus/dbus/v5 v5.2.2 // indirect
- github.com/google/uuid v1.6.0 // indirect
- github.com/gorilla/websocket v1.5.3 // indirect
- github.com/hack-pad/go-indexeddb v0.3.2 // indirect
- github.com/hack-pad/safejs v0.1.0 // indirect
- github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
- github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
- github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
- github.com/labstack/echo/v4 v4.15.3 // indirect
- github.com/labstack/gommon v0.5.0 // indirect
- github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
- github.com/leaanthony/gosod v1.0.4 // indirect
- github.com/leaanthony/slicer v1.6.0 // indirect
- github.com/leaanthony/u v1.1.1 // indirect
- github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
- github.com/mattn/go-colorable v0.1.14 // indirect
- github.com/mattn/go-isatty v0.0.22 // indirect
- github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
- github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
- github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
- github.com/pkg/errors v0.9.1 // indirect
- github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
- github.com/rivo/uniseg v0.4.7 // indirect
- github.com/rymdport/portal v0.4.2 // indirect
- github.com/samber/lo v1.49.1 // indirect
- github.com/shirou/gopsutil/v3 v3.24.5 // indirect
- github.com/shoenig/go-m1cpu v0.1.6 // indirect
- github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
- github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
- github.com/stretchr/testify v1.11.1 // indirect
- github.com/tklauser/go-sysconf v0.3.12 // indirect
- github.com/tklauser/numcpus v0.6.1 // indirect
- github.com/tkrajina/go-reflector v0.5.8 // indirect
- github.com/valyala/bytebufferpool v1.0.0 // indirect
- github.com/valyala/fasttemplate v1.2.2 // indirect
- github.com/wailsapp/go-webview2 v1.0.22 // indirect
- github.com/wailsapp/mimetype v1.4.1 // indirect
- github.com/yuin/goldmark v1.7.8 // indirect
- github.com/yusufpapurcu/wmi v1.2.4 // indirect
- golang.org/x/image v0.41.0 // indirect
- golang.org/x/net v0.57.0 // indirect
- golang.org/x/text v0.40.0 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
-)
-
-replace github.com/unitronix/betterdesk-agent => ../betterdesk-agent
-
-replace github.com/unitronix/betterdesk-server => ../betterdesk-server
diff --git a/betterdesk-support-agent/go.sum b/betterdesk-support-agent/go.sum
deleted file mode 100644
index f850aa1d..00000000
--- a/betterdesk-support-agent/go.sum
+++ /dev/null
@@ -1,174 +0,0 @@
-fyne.io/fyne/v2 v2.7.3 h1:xBT/iYbdnNHONWO38fZMBrVBiJG8rV/Jypmy4tVfRWE=
-fyne.io/fyne/v2 v2.7.3/go.mod h1:gu+dlIcZWSzKZmnrY8Fbnj2Hirabv2ek+AKsfQ2bBlw=
-fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
-fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
-git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
-git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
-github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
-github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
-github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
-github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
-github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
-github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
-github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
-github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
-github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
-github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
-github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
-github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
-github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
-github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
-github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
-github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
-github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
-github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
-github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
-github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
-github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
-github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
-github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
-github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
-github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
-github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
-github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
-github.com/go-text/typesetting v0.3.3 h1:ihGNJU9KzdK2QRDy1Bm7FT5RFQoYb+3n3EIhI/4eaQc=
-github.com/go-text/typesetting v0.3.3/go.mod h1:vIRUT25mLQaSh4C8H/lIsKppQz/Gdb8Pu/tNwpi52ts=
-github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8 h1:4KCscI9qYWMGTuz6BpJtbUSRzcBrUSSE0ENMJbNSrFs=
-github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
-github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
-github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
-github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
-github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
-github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
-github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
-github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
-github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
-github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
-github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
-github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
-github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
-github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
-github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
-github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
-github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
-github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
-github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
-github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
-github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/labstack/echo/v4 v4.15.3 h1:lIdG4kK5RdMyhwCwSc4AmSQsLBb3AVwok6S8PX/9kwQ=
-github.com/labstack/echo/v4 v4.15.3/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
-github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
-github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
-github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
-github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
-github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
-github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
-github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
-github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
-github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
-github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
-github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
-github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
-github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
-github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
-github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
-github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
-github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
-github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
-github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
-github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
-github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
-github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
-github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
-github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
-github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
-github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
-github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
-github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
-github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
-github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
-github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
-github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
-github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
-github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
-github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
-github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
-github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
-github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
-github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
-github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
-github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
-github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
-github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
-github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
-github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
-github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
-github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
-github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
-github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
-github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
-github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
-github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
-github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
-github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
-github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
-github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
-github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
-github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
-github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
-github.com/wailsapp/wails/v2 v2.13.0 h1:S7OgXWpj72V91unF8iDWJKbcS9ZpwCT3R0QVru4v2Mg=
-github.com/wailsapp/wails/v2 v2.13.0/go.mod h1:nVr/wSIEZ7xxKPkzK65mjpKpaOPQI2k4pvLwGR/i4kc=
-github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
-github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
-github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
-github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
-golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
-golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
-golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
-golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
-golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
-golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
-golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
-golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
-golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
-google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
-gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/betterdesk-support-agent/gui_linux.go b/betterdesk-support-agent/gui_linux.go
deleted file mode 100644
index 4a42e8a0..00000000
--- a/betterdesk-support-agent/gui_linux.go
+++ /dev/null
@@ -1,26 +0,0 @@
-//go:build linux
-
-package main
-
-import (
- "log"
- "os"
-)
-
-func prepWindowsGraphics() {}
-
-// prepLinuxDisplay logs the detected session; binary choice is handled by the launcher script.
-func prepLinuxDisplay() {
- switch {
- case os.Getenv("BETTERDESK_UI_BACKEND") == "wayland":
- log.Printf("[support-agent] UI backend: wayland (forced)")
- case os.Getenv("BETTERDESK_UI_BACKEND") == "x11":
- log.Printf("[support-agent] UI backend: x11 (forced)")
- case os.Getenv("WAYLAND_DISPLAY") != "" && os.Getenv("DISPLAY") == "":
- log.Printf("[support-agent] UI backend: wayland (WAYLAND_DISPLAY=%s)", os.Getenv("WAYLAND_DISPLAY"))
- default:
- if d := os.Getenv("DISPLAY"); d != "" {
- log.Printf("[support-agent] UI backend: x11 (DISPLAY=%s)", d)
- }
- }
-}
diff --git a/betterdesk-support-agent/gui_other.go b/betterdesk-support-agent/gui_other.go
deleted file mode 100644
index f898366f..00000000
--- a/betterdesk-support-agent/gui_other.go
+++ /dev/null
@@ -1,6 +0,0 @@
-//go:build !windows && !linux
-
-package main
-
-func prepWindowsGraphics() {}
-func prepLinuxDisplay() {}
diff --git a/betterdesk-support-agent/gui_windows.go b/betterdesk-support-agent/gui_windows.go
deleted file mode 100644
index 044ccfbc..00000000
--- a/betterdesk-support-agent/gui_windows.go
+++ /dev/null
@@ -1,37 +0,0 @@
-//go:build windows
-
-package main
-
-import (
- "os"
- "path/filepath"
-)
-
-func init() {
- prepWindowsGraphics()
-}
-
-// prepWindowsGraphics cleans up incomplete Mesa OpenGL sidecars left by older
-// Fyne builds. The default Wails UI uses WebView2 and does not need Mesa; an
-// orphan opengl32.dll without libgallium_wgl.dll still must not shadow system GL.
-func prepWindowsGraphics() {
- ensureMesaBesideExe()
- dir := exeDir()
- gl := filepath.Join(dir, "opengl32.dll")
- gallium := filepath.Join(dir, "libgallium_wgl.dll")
- if _, err := os.Stat(gl); err != nil {
- return
- }
- if _, err := os.Stat(gallium); err != nil {
- _ = os.Remove(gl)
- return
- }
- if os.Getenv("GALLIUM_DRIVER") == "" {
- _ = os.Setenv("GALLIUM_DRIVER", "llvmpipe")
- }
- if os.Getenv("LIBGL_ALWAYS_SOFTWARE") == "" {
- _ = os.Setenv("LIBGL_ALWAYS_SOFTWARE", "1")
- }
-}
-
-func prepLinuxDisplay() {}
diff --git a/betterdesk-support-agent/hardening.go b/betterdesk-support-agent/hardening.go
deleted file mode 100644
index 7b364fc6..00000000
--- a/betterdesk-support-agent/hardening.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package main
-
-import (
- "log"
- "os"
- "runtime"
- "strings"
-)
-
-// antiDebugChecks performs lightweight debugger heuristics on release builds.
-// Failures are logged only — false positives on VMs/containers are common.
-func antiDebugChecks() {
- if !isReleaseBuild() {
- return
- }
- if runtime.GOOS == "linux" {
- if data, err := os.ReadFile("/proc/self/status"); err == nil {
- for _, line := range strings.Split(string(data), "\n") {
- if strings.HasPrefix(line, "TracerPid:") {
- fields := strings.Fields(line)
- if len(fields) >= 2 && fields[1] != "0" {
- log.Printf("[hardening] tracer detected (TracerPid=%s)", fields[1])
- }
- }
- }
- }
- }
-}
diff --git a/betterdesk-support-agent/headless.go b/betterdesk-support-agent/headless.go
deleted file mode 100644
index ba271b25..00000000
--- a/betterdesk-support-agent/headless.go
+++ /dev/null
@@ -1,159 +0,0 @@
-package main
-
-import (
- "log"
- "os"
- "os/signal"
- "sync"
- "syscall"
- "time"
-
- "github.com/unitronix/betterdesk-support-agent/signalhost"
-)
-
-// runHeadless starts enrollment and the remote engine without a Fyne window.
-// Use on Windows hosts without OpenGL/WGL (common in VMs and some RDP sessions).
-func runHeadless() {
- brand := GetBranding()
-
- st, err := LoadState()
- if err != nil {
- log.Fatalf("[support-agent] state: %v", err)
- }
- setLang(st.Language)
-
- engine := NewEngine(version)
- engine.SetCallbacks(headlessConsent(brand, st), func(string, string, string) {}, func(string) {})
-
- log.Printf("[support-agent] %s starting headless (device=%s)", version, st.DeviceID)
-
- var hostMu sync.Mutex
- var host *signalhost.Host
- stopSignalHost := func() {
- hostMu.Lock()
- activeHost := host
- host = nil
- hostMu.Unlock()
- if activeHost != nil {
- activeHost.Stop()
- }
- }
- startSignalHost := func() {
- hostMu.Lock()
- if host != nil {
- hostMu.Unlock()
- return
- }
- hostMu.Unlock()
-
- candidate, reason := newSignalHost(brand, st, true, signalHostCallbacks{
- consent: func(operator string) bool {
- return headlessConsent(brand, st)("signal", operator)
- },
- audit: func(policy hostCapabilityPolicy) {
- auditHostCapabilityPolicy(hostCapabilityAuditTransportSignal, policy)
- },
- })
- if candidate == nil {
- log.Printf("[support-agent] headless RustDesk-compatible host disabled: %s", reason)
- return
- }
- if !candidate.Start() {
- log.Printf("[support-agent] headless RustDesk-compatible host disabled: access policy changed")
- return
- }
-
- hostMu.Lock()
- if host == nil {
- host = candidate
- candidate = nil
- }
- hostMu.Unlock()
- if candidate != nil {
- candidate.Stop()
- return
- }
- log.Printf("[support-agent] headless RustDesk-compatible host started")
- }
-
- if brand.HasConnection() {
- go headlessBootstrap(brand, st, engine, startSignalHost, stopSignalHost)
- }
-
- sig := make(chan os.Signal, 1)
- signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
- <-sig
- log.Printf("[support-agent] shutting down")
- stopSignalHost()
- engine.Stop()
-}
-
-func headlessBootstrap(brand Branding, st *AppState, engine *Engine, onApproved, onRejected func()) {
- startApproved := func() {
- if err := SyncAccessPassword(brand, st); err != nil {
- log.Printf("[support-agent] access password sync: %v", err)
- }
- policy := accessPolicyFor(brand, st)
- if !policy.allowsSignalHost(true) {
- // A headless process has no trustworthy local consent surface.
- // Fail before registering the CDAP engine so supervised, disabled,
- // and passwordless modes cannot advertise an unreachable session.
- log.Printf("[support-agent] headless remote access disabled: %s", policy.signalHostDisabledReason(true))
- return
- }
- if !engine.Running() {
- if err := engine.Start(st); err != nil {
- log.Printf("[support-agent] engine start: %v", err)
- return
- }
- }
- if onApproved != nil {
- onApproved()
- }
- StartEnrollmentRevalidation(brand, st, version, enrollmentRevalidationInterval, func(result EnrollmentStatus) {
- if result.Status != EnrollmentRejected {
- return
- }
- log.Printf("[support-agent] enrollment revoked: %s", result.Message)
- if onRejected != nil {
- onRejected()
- }
- engine.Stop()
- })
- }
-
- res, err := EnsureEnrolled(brand, st, version)
- if err != nil {
- log.Printf("[support-agent] enrollment: %v", err)
- return
- }
- switch res.Status {
- case EnrollmentApproved:
- startApproved()
- case EnrollmentPending:
- log.Printf("[support-agent] enrollment pending: %s", res.Message)
- StartEnrollmentPoll(brand, st, version, 5*time.Second, func(u EnrollmentStatus) {
- if u.Status == EnrollmentApproved {
- startApproved()
- }
- })
- case EnrollmentRejected:
- log.Printf("[support-agent] enrollment rejected: %s", res.Message)
- }
-}
-
-func headlessConsent(brand Branding, st *AppState) func(string, string) bool {
- return func(sessionID, operator string) bool {
- policy := accessPolicyFor(brand, st)
- if policy.allowsUnattended() {
- log.Printf("[support-agent] headless consent auto-allow session=%s operator=%s", sessionID, operator)
- return true
- }
- if policy.mode == AccessDisabled {
- log.Printf("[support-agent] headless consent denied (access disabled) session=%s operator=%s", sessionID, operator)
- return false
- }
- log.Printf("[support-agent] headless consent denied (no approved unattended policy) session=%s operator=%s", sessionID, operator)
- return false
- }
-}
diff --git a/betterdesk-support-agent/help.go b/betterdesk-support-agent/help.go
deleted file mode 100644
index 33372656..00000000
--- a/betterdesk-support-agent/help.go
+++ /dev/null
@@ -1,100 +0,0 @@
-package main
-
-import (
- "fmt"
- "net/http"
- "os"
- "strings"
-)
-
-// SendHelpRequest delivers a help request through CDAP, with REST fallback.
-func SendHelpRequest(engine *Engine, brand Branding, st *AppState, message string) error {
- if !brand.HasConnection() {
- return fmt.Errorf("no server address configured")
- }
- enrolled := st.IsEnrolled()
- // #region agent log
- debugLog("H4", "help.go:SendHelpRequest", "entry", map[string]any{
- "is_enrolled": enrolled, "engine_running": engine != nil && engine.Running(),
- "api_base": apiBaseURL(brand),
- })
- // #endregion
- if !enrolled {
- return fmt.Errorf("device not enrolled")
- }
- message = strings.TrimSpace(message)
- if message == "" {
- return fmt.Errorf("message required")
- }
-
- if engine != nil {
- cdapErr := engine.RequestHelp(st, message)
- if cdapErr == nil {
- // #region agent log
- debugLog("H7", "help.go:SendHelpRequest", "cdap help ok", nil)
- // #endregion
- return nil
- }
- if !isHelpGatewayError(cdapErr) {
- // #region agent log
- debugLog("H7", "help.go:SendHelpRequest", "cdap help fatal", map[string]any{"error": cdapErr.Error()})
- // #endregion
- return cdapErr
- }
- // #region agent log
- debugLog("H7", "help.go:SendHelpRequest", "cdap help fallback", map[string]any{"error": cdapErr.Error()})
- // #endregion
- }
- err := sendHelpViaAPI(brand, st, message)
- // #region agent log
- if err != nil {
- debugLog("H7", "help.go:SendHelpRequest", "rest help failed", map[string]any{"error": err.Error()})
- } else {
- debugLog("H7", "help.go:SendHelpRequest", "rest help ok", nil)
- }
- // #endregion
- return err
-}
-
-func isHelpGatewayError(err error) bool {
- if err == nil {
- return false
- }
- s := err.Error()
- return strings.Contains(s, "gateway not connected") ||
- strings.Contains(s, "not connected to gateway") ||
- strings.Contains(s, "connect to gateway") ||
- strings.Contains(s, "engine not ready")
-}
-
-func sendHelpViaAPI(brand Branding, st *AppState, message string) error {
- deviceID, _, _, _ := st.Snapshot()
- st.mu.Lock()
- token := st.DeviceToken
- st.mu.Unlock()
-
- hostname, _ := os.Hostname()
- if hostname == "" {
- hostname = "unknown"
- }
-
- payload := map[string]string{
- "device_id": deviceID,
- "device_token": token,
- "hostname": hostname,
- "message": message,
- }
- url := apiBaseURL(brand) + "/devices/self/help-request"
- var ack struct {
- ID int64 `json:"id"`
- Status string `json:"status"`
- }
- code, err := apiJSON(http.MethodPost, url, payload, &ack)
- if err != nil {
- return err
- }
- if code != http.StatusOK {
- return fmt.Errorf("help request failed (HTTP %d)", code)
- }
- return nil
-}
diff --git a/betterdesk-support-agent/host_capability_audit.go b/betterdesk-support-agent/host_capability_audit.go
deleted file mode 100644
index 038a27cd..00000000
--- a/betterdesk-support-agent/host_capability_audit.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package main
-
-const (
- hostCapabilityAuditTransportCDAP = "cdap"
- hostCapabilityAuditTransportSignal = "signal"
-)
-
-// auditHostCapabilityPolicy records the fixed policy outcome only. The
-// records cannot contain credentials, grant presentations, clipboard content,
-// terminal data, chat text, or arbitrary remote input.
-func auditHostCapabilityPolicy(transport string, policy hostCapabilityPolicy) {
- appLogInfo("host_capability_policy", "host capability policy evaluated", map[string]any{
- "transport": normalizeHostCapabilityAuditTransport(transport),
- "features": policy.auditRecords(),
- })
-}
-
-func normalizeHostCapabilityAuditTransport(transport string) string {
- switch transport {
- case hostCapabilityAuditTransportCDAP, hostCapabilityAuditTransportSignal:
- return transport
- default:
- return "unknown"
- }
-}
diff --git a/betterdesk-support-agent/host_capability_policy.go b/betterdesk-support-agent/host_capability_policy.go
deleted file mode 100644
index eabea00a..00000000
--- a/betterdesk-support-agent/host_capability_policy.go
+++ /dev/null
@@ -1,208 +0,0 @@
-package main
-
-// hostFeature names a remote-host operation. These values intentionally match
-// the session-grant vocabulary where it exists, so a future adapter can use
-// the same policy result for both CDAP and compatibility transports.
-type hostFeature string
-
-const (
- hostFeatureScreenView hostFeature = "screen_view"
- hostFeatureInput hostFeature = "input"
- hostFeatureClipboard hostFeature = "clipboard"
- hostFeatureFiles hostFeature = "files"
- hostFeatureTerminal hostFeature = "terminal"
- hostFeatureChat hostFeature = "chat"
- hostFeatureAudio hostFeature = "system_audio"
- hostFeatureMultiMonitor hostFeature = "multi_monitor"
- hostFeaturePrivacyMode hostFeature = "privacy_mode"
- hostFeatureBlockInput hostFeature = "block_input"
- hostFeatureRestart hostFeature = "restart"
- hostFeatureRecording hostFeature = "recording"
-)
-
-var hostFeatureOrder = []hostFeature{
- hostFeatureScreenView,
- hostFeatureInput,
- hostFeatureClipboard,
- hostFeatureFiles,
- hostFeatureTerminal,
- hostFeatureChat,
- hostFeatureAudio,
- hostFeatureMultiMonitor,
- hostFeaturePrivacyMode,
- hostFeatureBlockInput,
- hostFeatureRestart,
- hostFeatureRecording,
-}
-
-// hostFeatureReason is deliberately a small, fixed vocabulary. It is safe to
-// retain in local audit logs because it never contains a credential, grant,
-// clipboard content, terminal input, or other request payload.
-type hostFeatureReason string
-
-const (
- hostFeatureEnabled hostFeatureReason = "enabled"
- hostFeatureDisabledByBranding hostFeatureReason = "disabled_by_branding"
- hostFeatureGrantBindingUnavailable hostFeatureReason = "session_grant_binding_unavailable"
- hostFeatureAudioPipelineUnavailable hostFeatureReason = "audio_pipeline_unavailable"
- hostFeatureSingleCaptureSource hostFeatureReason = "single_capture_source_only"
- hostFeaturePrivacyCurtainUnavailable hostFeatureReason = "privacy_curtain_unavailable"
- hostFeatureLocalInputBlockUnavailable hostFeatureReason = "local_input_block_unavailable"
- hostFeatureRestartGrantBindingMissing hostFeatureReason = "restart_grant_binding_unavailable"
- hostFeatureRecordingPipelineUnavailable hostFeatureReason = "recording_pipeline_unavailable"
- hostFeatureUnknown hostFeatureReason = "unknown_feature"
-)
-
-// hostFeatureStatus is the complete local decision for one feature.
-type hostFeatureStatus struct {
- Feature hostFeature
- Allowed bool
- Reason hostFeatureReason
-}
-
-// hostCapabilityPolicy is the one conservative source of truth for what the
-// Support Agent may expose. A feature is allowed only when branding permits it
-// and the current host path can enforce that permission locally.
-type hostCapabilityPolicy struct {
- statuses map[hostFeature]hostFeatureStatus
-}
-
-func hostCapabilityPolicyFor(branding Branding) hostCapabilityPolicy {
- flags := branding.Capabilities
- policy := hostCapabilityPolicy{statuses: make(map[hostFeature]hostFeatureStatus, len(hostFeatureOrder))}
-
- // Desktop capture and remote input share the current CDAP configuration
- // switch. They are the only features with an enforceable admission path:
- // the CDAP adapter binds them to a signed passive grant, while the relay
- // host applies its local password, TOTP, and consent checks.
- desktopEnabled := hostFeatureRequested(featureFlag(flags, hostFeatureScreenView), true)
- policy.set(hostFeatureScreenView, desktopEnabled, hostFeatureDisabledByBranding)
- policy.set(hostFeatureInput, desktopEnabled, hostFeatureDisabledByBranding)
-
- // These handlers exist in the shared agent, but their independent CDAP
- // messages are not bound to the Support Agent's signed session grant or
- // active-session state. Advertising them would create a policy bypass.
- policy.setUnavailable(hostFeatureClipboard, featureFlag(flags, hostFeatureClipboard), hostFeatureGrantBindingUnavailable)
- policy.setUnavailable(hostFeatureFiles, featureFlag(flags, hostFeatureFiles), hostFeatureGrantBindingUnavailable)
- policy.setUnavailable(hostFeatureTerminal, featureFlag(flags, hostFeatureTerminal), hostFeatureGrantBindingUnavailable)
- policy.setUnavailable(hostFeatureChat, featureFlag(flags, hostFeatureChat), hostFeatureGrantBindingUnavailable)
-
- // The remaining operations have no enforceable host implementation. They
- // must stay denied even if a signed branding profile requests them.
- policy.setUnavailable(hostFeatureAudio, featureFlag(flags, hostFeatureAudio), hostFeatureAudioPipelineUnavailable)
- policy.setUnavailable(hostFeatureMultiMonitor, featureFlag(flags, hostFeatureMultiMonitor), hostFeatureSingleCaptureSource)
- policy.setUnavailable(hostFeaturePrivacyMode, featureFlag(flags, hostFeaturePrivacyMode), hostFeaturePrivacyCurtainUnavailable)
- policy.setUnavailable(hostFeatureBlockInput, featureFlag(flags, hostFeatureBlockInput), hostFeatureLocalInputBlockUnavailable)
- policy.setUnavailable(hostFeatureRestart, featureFlag(flags, hostFeatureRestart), hostFeatureRestartGrantBindingMissing)
- policy.setUnavailable(hostFeatureRecording, featureFlag(flags, hostFeatureRecording), hostFeatureRecordingPipelineUnavailable)
-
- return policy
-}
-
-func featureFlag(flags *CapabilityFlags, feature hostFeature) *bool {
- if flags == nil {
- return nil
- }
- switch feature {
- case hostFeatureScreenView, hostFeatureInput:
- return flags.Desktop
- case hostFeatureClipboard:
- return flags.Clipboard
- case hostFeatureFiles:
- return flags.Files
- case hostFeatureTerminal:
- return flags.Terminal
- case hostFeatureChat:
- return flags.Chat
- case hostFeatureAudio:
- return flags.Audio
- case hostFeatureMultiMonitor:
- return flags.MultiMonitor
- case hostFeaturePrivacyMode:
- return flags.PrivacyMode
- case hostFeatureBlockInput:
- return flags.BlockInput
- case hostFeatureRestart:
- return flags.Restart
- case hostFeatureRecording:
- return flags.Recording
- default:
- return nil
- }
-}
-
-func hostFeatureRequested(flag *bool, defaultValue bool) bool {
- if flag == nil {
- return defaultValue
- }
- return *flag
-}
-
-func (p hostCapabilityPolicy) set(feature hostFeature, allowed bool, disabledReason hostFeatureReason) {
- reason := hostFeatureEnabled
- if !allowed {
- reason = disabledReason
- }
- p.statuses[feature] = hostFeatureStatus{
- Feature: feature,
- Allowed: allowed,
- Reason: reason,
- }
-}
-
-func (p hostCapabilityPolicy) setUnavailable(feature hostFeature, profileFlag *bool, unavailableReason hostFeatureReason) {
- reason := unavailableReason
- if profileFlag != nil && !*profileFlag {
- reason = hostFeatureDisabledByBranding
- }
- p.statuses[feature] = hostFeatureStatus{
- Feature: feature,
- Allowed: false,
- Reason: reason,
- }
-}
-
-func (p hostCapabilityPolicy) allows(feature hostFeature) bool {
- status, ok := p.statuses[feature]
- return ok && status.Allowed
-}
-
-func (p hostCapabilityPolicy) status(feature hostFeature) hostFeatureStatus {
- if status, ok := p.statuses[feature]; ok {
- return status
- }
- return hostFeatureStatus{Feature: feature, Reason: hostFeatureUnknown}
-}
-
-func (p hostCapabilityPolicy) statusesInOrder() []hostFeatureStatus {
- statuses := make([]hostFeatureStatus, 0, len(hostFeatureOrder))
- for _, feature := range hostFeatureOrder {
- statuses = append(statuses, p.status(feature))
- }
- return statuses
-}
-
-// hostFeatureAuditRecord contains only code-defined capability decisions.
-// It intentionally has no field that could carry request content or secrets.
-type hostFeatureAuditRecord struct {
- Feature string `json:"feature"`
- Decision string `json:"decision"`
- Reason string `json:"reason"`
-}
-
-func (p hostCapabilityPolicy) auditRecords() []hostFeatureAuditRecord {
- statuses := p.statusesInOrder()
- records := make([]hostFeatureAuditRecord, 0, len(statuses))
- for _, status := range statuses {
- decision := "denied"
- if status.Allowed {
- decision = "allowed"
- }
- records = append(records, hostFeatureAuditRecord{
- Feature: string(status.Feature),
- Decision: decision,
- Reason: string(status.Reason),
- })
- }
- return records
-}
diff --git a/betterdesk-support-agent/host_capability_policy_test.go b/betterdesk-support-agent/host_capability_policy_test.go
deleted file mode 100644
index 49b2b8b7..00000000
--- a/betterdesk-support-agent/host_capability_policy_test.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "strings"
- "testing"
-)
-
-func TestHostCapabilityPolicyAllowsOnlyGrantBoundDesktopAndInput(t *testing.T) {
- policy := hostCapabilityPolicyFor(Branding{})
-
- for _, feature := range []hostFeature{hostFeatureScreenView, hostFeatureInput} {
- status := policy.status(feature)
- if !status.Allowed || status.Reason != hostFeatureEnabled {
- t.Fatalf("%s status = %#v, want allowed/enabled", feature, status)
- }
- }
-
- for _, feature := range []hostFeature{
- hostFeatureClipboard,
- hostFeatureFiles,
- hostFeatureTerminal,
- hostFeatureChat,
- hostFeatureAudio,
- hostFeatureMultiMonitor,
- hostFeaturePrivacyMode,
- hostFeatureBlockInput,
- hostFeatureRestart,
- hostFeatureRecording,
- } {
- if status := policy.status(feature); status.Allowed {
- t.Fatalf("%s must stay denied until its host path is enforceable: %#v", feature, status)
- }
- }
-}
-
-func TestHostCapabilityPolicyFailsClosedWhenBrandingRequestsUnavailableFeatures(t *testing.T) {
- enabled := true
- disabled := false
- branding := Branding{Capabilities: &CapabilityFlags{
- Desktop: &disabled,
- Files: &enabled,
- Clipboard: &enabled,
- Audio: &enabled,
- Terminal: &enabled,
- Chat: &enabled,
- MultiMonitor: &enabled,
- PrivacyMode: &enabled,
- BlockInput: &enabled,
- Restart: &enabled,
- Recording: &enabled,
- }}
-
- policy := hostCapabilityPolicyFor(branding)
- if status := policy.status(hostFeatureScreenView); status.Allowed || status.Reason != hostFeatureDisabledByBranding {
- t.Fatalf("screen_view status = %#v, want branding denial", status)
- }
- if status := policy.status(hostFeatureInput); status.Allowed || status.Reason != hostFeatureDisabledByBranding {
- t.Fatalf("input status = %#v, want branding denial", status)
- }
-
- wantReasons := map[hostFeature]hostFeatureReason{
- hostFeatureClipboard: hostFeatureGrantBindingUnavailable,
- hostFeatureFiles: hostFeatureGrantBindingUnavailable,
- hostFeatureTerminal: hostFeatureGrantBindingUnavailable,
- hostFeatureChat: hostFeatureGrantBindingUnavailable,
- hostFeatureAudio: hostFeatureAudioPipelineUnavailable,
- hostFeatureMultiMonitor: hostFeatureSingleCaptureSource,
- hostFeaturePrivacyMode: hostFeaturePrivacyCurtainUnavailable,
- hostFeatureBlockInput: hostFeatureLocalInputBlockUnavailable,
- hostFeatureRestart: hostFeatureRestartGrantBindingMissing,
- hostFeatureRecording: hostFeatureRecordingPipelineUnavailable,
- }
- for feature, reason := range wantReasons {
- status := policy.status(feature)
- if status.Allowed || status.Reason != reason {
- t.Fatalf("%s status = %#v, want denied/%s", feature, status, reason)
- }
- }
-
- caps := branding.incomingCapabilities()
- if caps.Desktop || caps.Files || caps.Clipboard || caps.Audio || caps.Terminal || caps.Restart {
- t.Fatalf("shared-agent switches must fail closed: %#v", caps)
- }
-}
-
-func TestHostCapabilityAuditRecordsContainOnlyFixedPolicyMetadata(t *testing.T) {
- records := hostCapabilityPolicyFor(Branding{}).auditRecords()
- if len(records) != len(hostFeatureOrder) {
- t.Fatalf("got %d audit records, want %d", len(records), len(hostFeatureOrder))
- }
-
- encoded, err := json.Marshal(records)
- if err != nil {
- t.Fatalf("marshal audit records: %v", err)
- }
- for _, forbidden := range []string{"password", "token", "grant_presentation", "clipboard_data", "terminal_data", "chat_text"} {
- if strings.Contains(string(encoded), forbidden) {
- t.Fatalf("audit records leaked forbidden field %q: %s", forbidden, encoded)
- }
- }
- for _, record := range records {
- if record.Feature == "" || record.Decision == "" || record.Reason == "" {
- t.Fatalf("incomplete audit record: %#v", record)
- }
- }
-}
-
-func TestHostCapabilityAuditTransportIsAllowlisted(t *testing.T) {
- if got := normalizeHostCapabilityAuditTransport(hostCapabilityAuditTransportCDAP); got != hostCapabilityAuditTransportCDAP {
- t.Fatalf("cdap transport = %q", got)
- }
- if got := normalizeHostCapabilityAuditTransport(hostCapabilityAuditTransportSignal); got != hostCapabilityAuditTransportSignal {
- t.Fatalf("signal transport = %q", got)
- }
- if got := normalizeHostCapabilityAuditTransport("remote-secret-token"); got != "unknown" {
- t.Fatalf("unrecognized transport = %q, want unknown", got)
- }
-}
-
-func TestSignalHostReceivesOnlyNonSecretCapabilityAudit(t *testing.T) {
- st := &AppState{
- DeviceID: "BD-TEST",
- AccessMode: AccessUnattended,
- AccessPassword: "secret12",
- }
- var got []hostFeatureAuditRecord
- host, reason := newSignalHost(
- Branding{ServerAddress: "https://desk.example.test", AllowUnattended: true},
- st,
- true,
- signalHostCallbacks{
- audit: func(policy hostCapabilityPolicy) {
- got = policy.auditRecords()
- },
- },
- )
- if host == nil || reason != "" {
- t.Fatalf("newSignalHost() = (%v, %q), want host without reason", host, reason)
- }
- encoded, err := json.Marshal(got)
- if err != nil {
- t.Fatalf("marshal signal-host audit: %v", err)
- }
- if strings.Contains(string(encoded), st.AccessPassword) {
- t.Fatalf("signal-host audit leaked access password: %s", encoded)
- }
- if len(got) != len(hostFeatureOrder) {
- t.Fatalf("got %d audit records, want %d", len(got), len(hostFeatureOrder))
- }
-}
diff --git a/betterdesk-support-agent/i18n.go b/betterdesk-support-agent/i18n.go
deleted file mode 100644
index df944e1c..00000000
--- a/betterdesk-support-agent/i18n.go
+++ /dev/null
@@ -1,218 +0,0 @@
-package main
-
-import (
- "embed"
- "encoding/json"
- "log"
- "strings"
- "sync"
-)
-
-//go:embed locales/*.json
-var localeFS embed.FS
-
-// SupportedLocales lists UI languages (same set as web-nodejs console).
-var SupportedLocales = []string{
- "ar", "cs", "da", "de", "en", "es", "fi", "fr", "hi", "hu", "id", "it",
- "ja", "ko", "nb", "nl", "pl", "pt", "ro", "sv", "th", "tr", "uk", "vi",
- "zh", "zh-TW",
-}
-
-var (
- langMu sync.RWMutex
- activeLang = "en"
- translation map[string]map[string]string
- localeLabels map[string]string
-)
-
-func init() {
- loadEmbeddedLocales()
-}
-
-func loadEmbeddedLocales() {
- translation = make(map[string]map[string]string, len(SupportedLocales))
- localeLabels = make(map[string]string, len(SupportedLocales))
-
- for _, code := range SupportedLocales {
- path := "locales/" + code + ".json"
- data, err := localeFS.ReadFile(path)
- if err != nil {
- log.Printf("[i18n] missing locale %s: %v", code, err)
- continue
- }
- var m map[string]string
- if err := json.Unmarshal(data, &m); err != nil {
- log.Printf("[i18n] parse %s: %v", code, err)
- continue
- }
- translation[code] = m
- localeLabels[code] = languageNativeName(code)
- }
-}
-
-// setLang sets the active UI language when supported.
-func setLang(lang string) {
- lang = normalizeLocale(lang)
- langMu.Lock()
- defer langMu.Unlock()
- if _, ok := translation[lang]; ok {
- activeLang = lang
- }
-}
-
-// t returns the translated string for key, falling back to English then the key.
-func t(key string) string {
- langMu.RLock()
- defer langMu.RUnlock()
- if m, ok := translation[activeLang]; ok {
- if v, ok := m[key]; ok && v != "" {
- return v
- }
- }
- if v, ok := translation["en"][key]; ok {
- return v
- }
- return key
-}
-
-// normalizeLocale maps OS/browser tags to supported codes.
-func normalizeLocale(tag string) string {
- tag = strings.TrimSpace(tag)
- if tag == "" {
- return "en"
- }
- tag = strings.ReplaceAll(tag, "_", "-")
- lower := strings.ToLower(tag)
-
- for _, code := range SupportedLocales {
- if strings.EqualFold(code, tag) || strings.EqualFold(code, lower) {
- return code
- }
- }
- base := lower
- if i := strings.IndexAny(base, "-@."); i >= 0 {
- base = base[:i]
- }
- switch base {
- case "nb", "no", "nn":
- return "nb"
- case "zh":
- if strings.HasPrefix(lower, "zh-tw") || strings.HasPrefix(lower, "zh-hk") || strings.HasPrefix(lower, "zh-mo") {
- return "zh-TW"
- }
- return "zh"
- case "pt":
- return "pt"
- }
- for _, code := range SupportedLocales {
- if strings.EqualFold(code, base) {
- return code
- }
- }
- return "en"
-}
-
-// resolveInitialLanguage picks persisted/branding/system language on first run.
-func resolveInitialLanguage(brandingDefault string) string {
- if brandingDefault != "" {
- if code := normalizeLocale(brandingDefault); hasLocale(code) {
- return code
- }
- }
- if sys := detectSystemLanguage(); sys != "" && hasLocale(sys) {
- return sys
- }
- return "en"
-}
-
-func hasLocale(code string) bool {
- _, ok := translation[code]
- return ok
-}
-
-// languageOptions returns locale codes with native labels for settings UI.
-func languageOptions() []string {
- out := make([]string, 0, len(SupportedLocales))
- for _, code := range SupportedLocales {
- if label, ok := localeLabels[code]; ok && label != "" {
- out = append(out, code+" — "+label)
- } else {
- out = append(out, code)
- }
- }
- return out
-}
-
-func languageCodeFromOption(opt string) string {
- if i := strings.Index(opt, " — "); i >= 0 {
- return strings.TrimSpace(opt[:i])
- }
- return strings.TrimSpace(opt)
-}
-
-func languageOptionForCode(code string) string {
- code = normalizeLocale(code)
- if label, ok := localeLabels[code]; ok && label != "" {
- return code + " — " + label
- }
- return code
-}
-
-func languageNativeName(code string) string {
- switch code {
- case "ar":
- return "العربية"
- case "cs":
- return "Čeština"
- case "da":
- return "Dansk"
- case "de":
- return "Deutsch"
- case "en":
- return "English"
- case "es":
- return "Español"
- case "fi":
- return "Suomi"
- case "fr":
- return "Français"
- case "hi":
- return "हिन्दी"
- case "hu":
- return "Magyar"
- case "id":
- return "Bahasa Indonesia"
- case "it":
- return "Italiano"
- case "ja":
- return "日本語"
- case "ko":
- return "한국어"
- case "nb":
- return "Norsk Bokmål"
- case "nl":
- return "Nederlands"
- case "pl":
- return "Polski"
- case "pt":
- return "Português"
- case "ro":
- return "Română"
- case "sv":
- return "Svenska"
- case "th":
- return "ไทย"
- case "tr":
- return "Türkçe"
- case "uk":
- return "Українська"
- case "vi":
- return "Tiếng Việt"
- case "zh":
- return "简体中文"
- case "zh-TW":
- return "繁體中文"
- default:
- return code
- }
-}
diff --git a/betterdesk-support-agent/i18n_test.go b/betterdesk-support-agent/i18n_test.go
deleted file mode 100644
index 6810a4ed..00000000
--- a/betterdesk-support-agent/i18n_test.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package main
-
-import (
- "testing"
-)
-
-func TestNormalizeLocale(t *testing.T) {
- cases := map[string]string{
- "pl_PL.UTF-8": "pl",
- "en-US": "en",
- "zh-CN": "zh",
- "zh-TW": "zh-TW",
- "nb_NO": "nb",
- "pt-BR": "pt",
- "": "en",
- "xx": "en",
- }
- for in, want := range cases {
- if got := normalizeLocale(in); got != want {
- t.Fatalf("normalizeLocale(%q) = %q, want %q", in, got, want)
- }
- }
-}
-
-func TestEmbeddedLocales(t *testing.T) {
- for _, code := range SupportedLocales {
- if _, ok := translation[code]; !ok {
- t.Fatalf("missing embedded locale %s", code)
- }
- }
- enKeys := len(translation["en"])
- for _, code := range SupportedLocales {
- if len(translation[code]) != enKeys {
- t.Fatalf("locale %s has %d keys, en has %d", code, len(translation[code]), enKeys)
- }
- }
-}
-
-func TestTranslationFallback(tt *testing.T) {
- setLang("pl")
- if got := t("save"); got != "Zapisz" {
- tt.Fatalf("expected Polish save, got %q", got)
- }
- setLang("en")
-}
diff --git a/betterdesk-support-agent/install.go b/betterdesk-support-agent/install.go
deleted file mode 100644
index acd6934e..00000000
--- a/betterdesk-support-agent/install.go
+++ /dev/null
@@ -1,333 +0,0 @@
-package main
-
-import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
-)
-
-// Installation turns the portable binary into the "installer form": it copies
-// itself to a stable per-user location and registers autostart so the tray
-// agent launches at login. The same binary therefore serves both forms — run
-// it directly (portable) or with -install (installed).
-
-const installAppName = "betterdesk-support"
-
-// installDir returns the per-user directory the installed binary lives in.
-func installDir() (string, error) {
- switch runtime.GOOS {
- case "windows":
- base := os.Getenv("LOCALAPPDATA")
- if base == "" {
- base = os.Getenv("APPDATA")
- }
- if base == "" {
- return "", fmt.Errorf("LOCALAPPDATA not set")
- }
- return filepath.Join(base, "BetterDeskSupport"), nil
- case "darwin":
- home, err := os.UserHomeDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(home, "Applications", "BetterDeskSupport"), nil
- default: // linux
- base, err := os.UserConfigDir()
- if err != nil {
- home, _ := os.UserHomeDir()
- base = filepath.Join(home, ".local", "share")
- }
- return filepath.Join(base, installAppName, "bin"), nil
- }
-}
-
-// installedBinaryPath returns the path of the installed executable.
-func installedBinaryPath() (string, error) {
- dir, err := installDir()
- if err != nil {
- return "", err
- }
- name := installAppName
- if runtime.GOOS == "windows" {
- name += ".exe"
- }
- return filepath.Join(dir, name), nil
-}
-
-// Install copies the running binary to the install directory and registers
-// per-user autostart.
-func Install() error {
- src, err := os.Executable()
- if err != nil {
- return fmt.Errorf("locate executable: %w", err)
- }
- dst, err := installedBinaryPath()
- if err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
- return fmt.Errorf("create install dir: %w", err)
- }
- if err := copyLinuxUIBundle(src, dst); err != nil {
- return fmt.Errorf("copy binary: %w", err)
- }
- copyMesaCompanion(src, dst)
- if err := registerAutostart(dst); err != nil {
- return fmt.Errorf("register autostart: %w", err)
- }
- fmt.Printf("Installed to %s and registered autostart.\n", dst)
- return nil
-}
-
-// Uninstall removes autostart and installed binaries while preserving the
-// agent's persistent state for a later reinstall.
-func Uninstall() error {
- return uninstall(false)
-}
-
-// UninstallPurge removes autostart, installed binaries and persistent state.
-// It is intentionally separate so normal uninstall cannot destroy enrollment
-// identity or operator preferences.
-func UninstallPurge() error {
- return uninstall(true)
-}
-
-func uninstall(purge bool) error {
- if err := unregisterAutostart(); err != nil {
- return fmt.Errorf("remove autostart: %w", err)
- }
- dst, err := installedBinaryPath()
- if err != nil {
- return err
- }
- installPath := filepath.Dir(dst)
- if purge {
- if err := os.RemoveAll(installPath); err != nil {
- return fmt.Errorf("remove install directory: %w", err)
- }
- if err := os.RemoveAll(stateDir()); err != nil {
- return fmt.Errorf("remove state directory: %w", err)
- }
- } else {
- for _, name := range []string{
- filepath.Base(dst),
- "betterdesk-support-x11",
- "betterdesk-support-wayland",
- "opengl32.dll",
- "libgallium_wgl.dll",
- } {
- if err := os.Remove(filepath.Join(installPath, name)); err != nil && !os.IsNotExist(err) {
- return fmt.Errorf("remove installed binary %s: %w", name, err)
- }
- }
- // Remove only an empty binary directory; any remaining operator files
- // are deliberately preserved.
- if err := os.Remove(installPath); err != nil && !os.IsNotExist(err) {
- fmt.Printf("Preserved non-empty install directory %s.\n", installPath)
- }
- }
- if purge {
- fmt.Println("Uninstalled autostart entry and purged state.")
- } else {
- fmt.Println("Uninstalled autostart entry; persistent state preserved.")
- }
- return nil
-}
-
-// copyExecutable copies src to dst preserving the executable bit.
-func copyExecutable(src, dst string) error {
- data, err := os.ReadFile(src)
- if err != nil {
- return err
- }
- // Replace any previous install atomically.
- tmp := dst + ".tmp"
- if err := os.WriteFile(tmp, data, 0o755); err != nil {
- return err
- }
- if err := os.Rename(tmp, dst); err == nil {
- return nil
- } else if runtime.GOOS == "windows" {
- // Windows cannot replace an existing executable with Rename. Remove
- // only the previous target after the new bytes are safely staged.
- if removeErr := os.Remove(dst); removeErr != nil && !os.IsNotExist(removeErr) {
- _ = os.Remove(tmp)
- return removeErr
- }
- if replaceErr := os.Rename(tmp, dst); replaceErr != nil {
- _ = os.Remove(tmp)
- return replaceErr
- }
- return nil
- } else {
- _ = os.Remove(tmp)
- return err
- }
-}
-
-// copyLinuxUIBundle installs the session launcher plus X11/Wayland UI binaries on Linux.
-func copyLinuxUIBundle(src, dst string) error {
- if runtime.GOOS != "linux" {
- return copyExecutable(src, dst)
- }
- srcDir := filepath.Dir(src)
- x11 := filepath.Join(srcDir, "betterdesk-support-x11")
- wl := filepath.Join(srcDir, "betterdesk-support-wayland")
- launcher := filepath.Join(srcDir, "betterdesk-support")
- if _, err := os.Stat(x11); err != nil {
- return copyExecutable(src, dst)
- }
- installDir := filepath.Dir(dst)
- if err := copyExecutable(x11, filepath.Join(installDir, "betterdesk-support-x11")); err != nil {
- return err
- }
- if _, err := os.Stat(wl); err == nil {
- if err := copyExecutable(wl, filepath.Join(installDir, "betterdesk-support-wayland")); err != nil {
- return err
- }
- }
- script := launcher
- if st, err := os.Stat(launcher); err != nil || st.Mode()&0o111 == 0 {
- script = filepath.Join(srcDir, "scripts", "betterdesk-support-launcher.sh")
- }
- data, err := os.ReadFile(script)
- if err != nil {
- return copyExecutable(src, dst)
- }
- tmp := dst + ".tmp"
- if err := os.WriteFile(tmp, data, 0o755); err != nil {
- return err
- }
- return os.Rename(tmp, dst)
-}
-
-// copyMesaCompanion copies the complete Mesa software-OpenGL DLL set beside the
-// installed binary. Never copy opengl32.dll alone — it requires libgallium_wgl.dll.
-func copyMesaCompanion(srcExe, dstExe string) {
- srcDir := filepath.Dir(srcExe)
- dstDir := filepath.Dir(dstExe)
- required := []string{"opengl32.dll", "libgallium_wgl.dll"}
- for _, name := range required {
- if _, err := os.Stat(filepath.Join(srcDir, name)); err != nil {
- return
- }
- }
- for _, name := range required {
- _ = copyExecutable(filepath.Join(srcDir, name), filepath.Join(dstDir, name))
- }
-}
-
-// registerAutostart wires the installed binary to launch at user login.
-func registerAutostart(binPath string) error {
- switch runtime.GOOS {
- case "windows":
- return autostartWindows(binPath, true)
- case "darwin":
- return autostartDarwin(binPath, true)
- default:
- return autostartLinux(binPath, true)
- }
-}
-
-// unregisterAutostart removes the login entry.
-func unregisterAutostart() error {
- switch runtime.GOOS {
- case "windows":
- return autostartWindows("", false)
- case "darwin":
- return autostartDarwin("", false)
- default:
- return autostartLinux("", false)
- }
-}
-
-// ── Linux: XDG autostart .desktop ────────────────────────────────────
-
-func linuxAutostartPath() (string, error) {
- base, err := os.UserConfigDir()
- if err != nil {
- home, _ := os.UserHomeDir()
- base = filepath.Join(home, ".config")
- }
- return filepath.Join(base, "autostart", installAppName+".desktop"), nil
-}
-
-func autostartLinux(binPath string, enable bool) error {
- path, err := linuxAutostartPath()
- if err != nil {
- return err
- }
- if !enable {
- err := os.Remove(path)
- if os.IsNotExist(err) {
- return nil
- }
- return err
- }
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- return err
- }
- entry := fmt.Sprintf(`[Desktop Entry]
-Type=Application
-Name=%s
-Exec=%q
-Terminal=false
-X-GNOME-Autostart-enabled=true
-`, GetBranding().ProductName, binPath)
- return os.WriteFile(path, []byte(entry), 0o644)
-}
-
-// ── Windows: HKCU Run key via reg.exe ────────────────────────────────
-
-func autostartWindows(binPath string, enable bool) error {
- const key = `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`
- if !enable {
- cmd := exec.Command("reg", "delete", key, "/v", installAppName, "/f")
- hideConsole(cmd)
- _ = cmd.Run() // ignore "value not found"
- return nil
- }
- cmd := exec.Command("reg", "add", key, "/v", installAppName, "/t", "REG_SZ", "/d", binPath, "/f")
- hideConsole(cmd)
- return cmd.Run()
-}
-
-// ── macOS: LaunchAgent plist ─────────────────────────────────────────
-
-func darwinLaunchAgentPath() (string, error) {
- home, err := os.UserHomeDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(home, "Library", "LaunchAgents", "com.betterdesk.supportagent.plist"), nil
-}
-
-func autostartDarwin(binPath string, enable bool) error {
- path, err := darwinLaunchAgentPath()
- if err != nil {
- return err
- }
- if !enable {
- err := os.Remove(path)
- if os.IsNotExist(err) {
- return nil
- }
- return err
- }
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- return err
- }
- plist := fmt.Sprintf(`
-
-
-
- Label com.betterdesk.supportagent
- ProgramArguments %s
- RunAtLoad
-
-
-`, binPath)
- return os.WriteFile(path, []byte(plist), 0o644)
-}
diff --git a/betterdesk-support-agent/internal/brandprofile/profile.go b/betterdesk-support-agent/internal/brandprofile/profile.go
deleted file mode 100644
index dd2b6ffc..00000000
--- a/betterdesk-support-agent/internal/brandprofile/profile.go
+++ /dev/null
@@ -1,83 +0,0 @@
-// Package brandprofile provides authenticated branding envelopes for the
-// Support Agent. It deliberately uses a signing key rather than a symmetric
-// key derived from data stored alongside the profile.
-package brandprofile
-
-import (
- "crypto/ed25519"
- "crypto/subtle"
- "encoding/base64"
- "fmt"
-)
-
-var magic = []byte("BDBP2\x00")
-
-const signatureSize = ed25519.SignatureSize
-
-// Sign returns a versioned envelope containing an Ed25519 signature and the
-// original JSON profile. The public key is intentionally not part of the
-// envelope: callers embed it in the authenticated application artifact.
-func Sign(profile []byte, privateKey ed25519.PrivateKey) ([]byte, error) {
- if len(privateKey) != ed25519.PrivateKeySize {
- return nil, fmt.Errorf("invalid Ed25519 private key length")
- }
- if len(profile) == 0 {
- return nil, fmt.Errorf("empty branding profile")
- }
- signature := ed25519.Sign(privateKey, profile)
- out := make([]byte, 0, len(magic)+signatureSize+len(profile))
- out = append(out, magic...)
- out = append(out, signature...)
- out = append(out, profile...)
- return out, nil
-}
-
-// Verify validates an authenticated profile envelope and returns its original
-// JSON. It rejects unsigned, truncated, and tampered data.
-func Verify(envelope []byte, publicKey ed25519.PublicKey) ([]byte, error) {
- if len(publicKey) != ed25519.PublicKeySize {
- return nil, fmt.Errorf("invalid Ed25519 public key length")
- }
- if !IsSigned(envelope) {
- return nil, fmt.Errorf("not a signed branding profile")
- }
- offset := len(magic)
- if len(envelope) <= offset+signatureSize {
- return nil, fmt.Errorf("truncated branding profile")
- }
- signature := envelope[offset : offset+signatureSize]
- profile := envelope[offset+signatureSize:]
- if !ed25519.Verify(publicKey, profile, signature) {
- return nil, fmt.Errorf("branding profile signature verification failed")
- }
- return profile, nil
-}
-
-// IsSigned reports whether the blob uses the signed-profile envelope format.
-func IsSigned(blob []byte) bool {
- if len(blob) < len(magic) {
- return false
- }
- return subtle.ConstantTimeCompare(blob[:len(magic)], magic) == 1
-}
-
-// EncodePublicKey produces the compact base64 value used in the generated
-// public-key resource.
-func EncodePublicKey(key ed25519.PublicKey) (string, error) {
- if len(key) != ed25519.PublicKeySize {
- return "", fmt.Errorf("invalid Ed25519 public key length")
- }
- return base64.RawStdEncoding.EncodeToString(key), nil
-}
-
-// DecodePublicKey parses the compact public-key resource.
-func DecodePublicKey(encoded string) (ed25519.PublicKey, error) {
- raw, err := base64.RawStdEncoding.DecodeString(encoded)
- if err != nil {
- return nil, fmt.Errorf("decode branding public key: %w", err)
- }
- if len(raw) != ed25519.PublicKeySize {
- return nil, fmt.Errorf("invalid Ed25519 public key length")
- }
- return ed25519.PublicKey(raw), nil
-}
diff --git a/betterdesk-support-agent/internal/brandprofile/profile_test.go b/betterdesk-support-agent/internal/brandprofile/profile_test.go
deleted file mode 100644
index fcee2c11..00000000
--- a/betterdesk-support-agent/internal/brandprofile/profile_test.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package brandprofile
-
-import (
- "crypto/ed25519"
- "crypto/rand"
- "testing"
-)
-
-func TestSignedProfileRoundTrip(t *testing.T) {
- publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
- if err != nil {
- t.Fatal(err)
- }
- profile := []byte(`{"bundle_id":"bundle-1","server":{"address":"https://desk.example"}}`)
- envelope, err := Sign(profile, privateKey)
- if err != nil {
- t.Fatal(err)
- }
- if !IsSigned(envelope) {
- t.Fatal("expected signed envelope")
- }
- got, err := Verify(envelope, publicKey)
- if err != nil {
- t.Fatal(err)
- }
- if string(got) != string(profile) {
- t.Fatalf("profile mismatch: got %q want %q", got, profile)
- }
-}
-
-func TestSignedProfileRejectsTamperingAndWrongKey(t *testing.T) {
- publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
- if err != nil {
- t.Fatal(err)
- }
- envelope, err := Sign([]byte(`{"bundle_id":"bundle-1"}`), privateKey)
- if err != nil {
- t.Fatal(err)
- }
- envelope[len(envelope)-1] ^= 1
- if _, err := Verify(envelope, publicKey); err == nil {
- t.Fatal("expected tampering rejection")
- }
-
- otherPublic, _, err := ed25519.GenerateKey(rand.Reader)
- if err != nil {
- t.Fatal(err)
- }
- clean, err := Sign([]byte(`{"bundle_id":"bundle-1"}`), privateKey)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := Verify(clean, otherPublic); err == nil {
- t.Fatal("expected wrong-key rejection")
- }
-}
-
-func TestPublicKeyEncoding(t *testing.T) {
- publicKey, _, err := ed25519.GenerateKey(rand.Reader)
- if err != nil {
- t.Fatal(err)
- }
- encoded, err := EncodePublicKey(publicKey)
- if err != nil {
- t.Fatal(err)
- }
- decoded, err := DecodePublicKey(encoded)
- if err != nil {
- t.Fatal(err)
- }
- if !publicKey.Equal(decoded) {
- t.Fatal("public key did not round-trip")
- }
-}
diff --git a/betterdesk-support-agent/internal/brandseal/seal.go b/betterdesk-support-agent/internal/brandseal/seal.go
deleted file mode 100644
index 8605bc58..00000000
--- a/betterdesk-support-agent/internal/brandseal/seal.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package brandseal
-
-import (
- "crypto/aes"
- "crypto/cipher"
- "crypto/rand"
- "crypto/sha256"
- "encoding/binary"
- "fmt"
- "io"
-)
-
-var Magic = []byte("BDBR1\x00")
-
-func Seal(plaintext, salt []byte) ([]byte, error) {
- if len(salt) < 16 {
- return nil, fmt.Errorf("salt too short")
- }
- key := sha256.Sum256(append([]byte("betterdesk-branding-seal-v1|"), salt...))
- block, err := aes.NewCipher(key[:])
- if err != nil {
- return nil, err
- }
- gcm, err := cipher.NewGCM(block)
- if err != nil {
- return nil, err
- }
- nonce := make([]byte, gcm.NonceSize())
- if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
- return nil, err
- }
- sealed := gcm.Seal(nil, nonce, plaintext, Magic)
-
- out := make([]byte, 0, len(Magic)+4+len(salt)+len(nonce)+len(sealed))
- out = append(out, Magic...)
- var slen [4]byte
- binary.BigEndian.PutUint32(slen[:], uint32(len(salt)))
- out = append(out, slen[:]...)
- out = append(out, salt...)
- out = append(out, nonce...)
- out = append(out, sealed...)
- return out, nil
-}
-
-func Unseal(blob []byte) ([]byte, error) {
- if !IsSealed(blob) {
- return nil, fmt.Errorf("not a sealed branding blob")
- }
- off := len(Magic)
- slen := int(binary.BigEndian.Uint32(blob[off : off+4]))
- off += 4
- if slen < 16 || off+slen > len(blob) {
- return nil, fmt.Errorf("invalid salt length")
- }
- salt := blob[off : off+slen]
- off += slen
-
- key := sha256.Sum256(append([]byte("betterdesk-branding-seal-v1|"), salt...))
- block, err := aes.NewCipher(key[:])
- if err != nil {
- return nil, err
- }
- gcm, err := cipher.NewGCM(block)
- if err != nil {
- return nil, err
- }
- ns := gcm.NonceSize()
- if off+ns > len(blob) {
- return nil, fmt.Errorf("truncated nonce")
- }
- nonce := blob[off : off+ns]
- ciphertext := blob[off+ns:]
- return gcm.Open(nil, nonce, ciphertext, Magic)
-}
-
-func IsSealed(blob []byte) bool {
- return len(blob) >= len(Magic) && string(blob[:len(Magic)]) == string(Magic)
-}
diff --git a/betterdesk-support-agent/internal/brandseal/seal_test.go b/betterdesk-support-agent/internal/brandseal/seal_test.go
deleted file mode 100644
index 5c096ff7..00000000
--- a/betterdesk-support-agent/internal/brandseal/seal_test.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package brandseal
-
-import (
- "bytes"
- "crypto/rand"
- "testing"
-)
-
-func TestSealRoundTrip(t *testing.T) {
- plain := []byte(`{"server_key":"secret","server":{"address":"https://x"}}`)
- salt := make([]byte, 32)
- if _, err := rand.Read(salt); err != nil {
- t.Fatal(err)
- }
- blob, err := Seal(plain, salt)
- if err != nil {
- t.Fatal(err)
- }
- if !IsSealed(blob) {
- t.Fatal("expected sealed magic")
- }
- out, err := Unseal(blob)
- if err != nil {
- t.Fatal(err)
- }
- if !bytes.Equal(out, plain) {
- t.Fatalf("mismatch %q vs %q", out, plain)
- }
-}
-
-func TestSealTamperFails(t *testing.T) {
- plain := []byte(`{"a":1}`)
- salt := make([]byte, 32)
- _, _ = rand.Read(salt)
- blob, err := Seal(plain, salt)
- if err != nil {
- t.Fatal(err)
- }
- blob[len(blob)-1] ^= 0x55
- if _, err := Unseal(blob); err == nil {
- t.Fatal("expected auth failure")
- }
-}
diff --git a/betterdesk-support-agent/internal/interoperability/boundary.go b/betterdesk-support-agent/internal/interoperability/boundary.go
deleted file mode 100644
index 8db0ea19..00000000
--- a/betterdesk-support-agent/internal/interoperability/boundary.go
+++ /dev/null
@@ -1,201 +0,0 @@
-package interoperability
-
-import (
- "context"
- "errors"
- "io"
- "strings"
-)
-
-// SpecificationRevision identifies the first BetterDesk-owned observed-wire
-// specification family. It is a revision label, not a claim that any external
-// client's complete protocol has been implemented.
-const SpecificationRevision = "bd-desktop-wire-observation/v1"
-
-// Feature identifies a target-side operation that an adapter may request after
-// inbound session authorization. Feature declarations are not evidence that a
-// client version or platform supports the operation.
-type Feature string
-
-const (
- FeatureDesktop Feature = "desktop"
- FeatureInput Feature = "input"
- FeatureAudio Feature = "system_audio"
- FeatureClipboard Feature = "clipboard"
- FeatureFiles Feature = "files"
- FeatureTerminal Feature = "terminal"
- FeatureMonitor Feature = "multi_monitor"
- FeatureRestart Feature = "restart"
-)
-
-// Descriptor declares the narrow, reviewable scope of a future BetterDesk
-// adapter. SupportedFeatures must be backed by a reviewed vector and manual
-// lab result before it is exposed in a release.
-type Descriptor struct {
- ID string
- SpecificationRevision string
- SupportedFeatures []Feature
-}
-
-// Validate rejects ambiguous adapter declarations before they are registered.
-func (d Descriptor) Validate() error {
- if strings.TrimSpace(d.ID) == "" || strings.TrimSpace(d.SpecificationRevision) == "" {
- return ErrInvalidDescriptor
- }
- if len(d.SupportedFeatures) == 0 {
- return ErrInvalidDescriptor
- }
-
- seen := make(map[Feature]struct{}, len(d.SupportedFeatures))
- for _, feature := range d.SupportedFeatures {
- if !knownFeature(feature) {
- return ErrInvalidDescriptor
- }
- if _, duplicate := seen[feature]; duplicate {
- return ErrInvalidDescriptor
- }
- seen[feature] = struct{}{}
- }
- return nil
-}
-
-// InboundTransport is a transport that has already been accepted by the
-// surrounding relay or listener. Its shape intentionally does not expose a
-// dialer, listener, or controller-side connection workflow.
-type InboundTransport interface {
- io.ReadWriteCloser
-
- // PeerAddress is diagnostic metadata for the accepted peer. Implementations
- // must avoid placing credentials, grant material, or packet contents here.
- PeerAddress() string
-}
-
-// Adapter is the only wire-level entry point into the native Support Agent
-// session path. ServeInbound must fail closed when authorization fails and must
-// not retain or log grant presentation material.
-//
-// Implementations belong in this package tree and must not import the legacy
-// signalhost/proto surface as an implementation shortcut.
-type Adapter interface {
- Descriptor() Descriptor
- ServeInbound(context.Context, InboundTransport, SessionAuthorizer) error
-}
-
-// Admission is the transient request an adapter presents to the native passive
-// session path after it has identified an inbound peer. GrantPresentation is
-// opaque and is intentionally excluded from all result and audit types.
-type Admission struct {
- SessionID string
- OperatorID string
- Transport string
- RequestedFeatures []Feature
- GrantPresentation string
-}
-
-// Validate applies the minimum boundary checks before an authorizer evaluates
-// the server grant and local policy.
-func (a Admission) Validate() error {
- if strings.TrimSpace(a.SessionID) == "" ||
- strings.TrimSpace(a.OperatorID) == "" ||
- strings.TrimSpace(a.Transport) == "" ||
- strings.TrimSpace(a.GrantPresentation) == "" ||
- len(a.RequestedFeatures) == 0 {
- return ErrInvalidAdmission
- }
-
- seen := make(map[Feature]struct{}, len(a.RequestedFeatures))
- for _, feature := range a.RequestedFeatures {
- if !knownFeature(feature) {
- return ErrInvalidAdmission
- }
- if _, duplicate := seen[feature]; duplicate {
- return ErrInvalidAdmission
- }
- seen[feature] = struct{}{}
- }
- return nil
-}
-
-// SessionAuthorizer is implemented by the native passive-session core bridge.
-// It owns grant verification, local policy, consent, and session lifecycle;
-// the wire adapter only asks to begin an inbound session.
-type SessionAuthorizer interface {
- AuthorizeInbound(context.Context, Admission) (AuthorizedSession, error)
-}
-
-// AuthorizedSession is the non-secret, target-side result of successful
-// authorization. It exposes neither grant material nor a way to initiate a
-// connection to another peer.
-type AuthorizedSession interface {
- ID() string
- Allows(Feature) bool
- End(context.Context, EndReason) error
-}
-
-// EndReason describes why an inbound session ends without exposing wire
-// payloads, credentials, or implementation diagnostics.
-type EndReason string
-
-const (
- EndReasonPeerDisconnected EndReason = "peer_disconnected"
- EndReasonLocalDisconnect EndReason = "local_disconnect"
- EndReasonPolicyRevoked EndReason = "policy_revoked"
- EndReasonTransportError EndReason = "transport_error"
-)
-
-var (
- // ErrInvalidDescriptor indicates an adapter declaration that cannot be
- // reviewed against a specific BetterDesk specification revision.
- ErrInvalidDescriptor = errors.New("interoperability: invalid adapter descriptor")
- // ErrInvalidAdmission indicates an incomplete or ambiguous inbound request.
- ErrInvalidAdmission = errors.New("interoperability: invalid inbound admission")
-)
-
-// AuditedSurface identifies a pre-existing compatibility dependency that is
-// intentionally outside the independently-owned adapter boundary.
-type AuditedSurface struct {
- Path string
- Role string
- Status string
-}
-
-const temporaryAuditedStatus = "temporary_audited"
-
-// TemporaryAuditedSurfaces returns a fresh inventory of the current legacy
-// wire path. Inclusion is an audit marker, not provenance approval or a reason
-// for a future adapter to import these packages.
-func TemporaryAuditedSurfaces() []AuditedSurface {
- return []AuditedSurface{
- {
- Path: "betterdesk-support-agent/signalhost",
- Role: "legacy desktop-client relay host",
- Status: temporaryAuditedStatus,
- },
- {
- Path: "betterdesk-server/codec",
- Role: "legacy wire framing dependency",
- Status: temporaryAuditedStatus,
- },
- {
- Path: "betterdesk-server/proto",
- Role: "legacy generated message dependency",
- Status: temporaryAuditedStatus,
- },
- }
-}
-
-func knownFeature(feature Feature) bool {
- switch feature {
- case FeatureDesktop,
- FeatureInput,
- FeatureAudio,
- FeatureClipboard,
- FeatureFiles,
- FeatureTerminal,
- FeatureMonitor,
- FeatureRestart:
- return true
- default:
- return false
- }
-}
diff --git a/betterdesk-support-agent/internal/interoperability/boundary_test.go b/betterdesk-support-agent/internal/interoperability/boundary_test.go
deleted file mode 100644
index 8bdf4a80..00000000
--- a/betterdesk-support-agent/internal/interoperability/boundary_test.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package interoperability
-
-import (
- "context"
- "errors"
- "testing"
-)
-
-func TestDescriptorAndAdmissionValidation(t *testing.T) {
- descriptor := Descriptor{
- ID: "betterdesk-observed-wire",
- SpecificationRevision: SpecificationRevision,
- SupportedFeatures: []Feature{FeatureDesktop, FeatureInput},
- }
- if err := descriptor.Validate(); err != nil {
- t.Fatalf("valid descriptor: %v", err)
- }
-
- descriptor.SupportedFeatures = append(descriptor.SupportedFeatures, FeatureInput)
- if err := descriptor.Validate(); !errors.Is(err, ErrInvalidDescriptor) {
- t.Fatalf("duplicate feature error = %v, want invalid descriptor", err)
- }
-
- admission := Admission{
- SessionID: "session-42",
- OperatorID: "operator-42",
- Transport: "relay",
- RequestedFeatures: []Feature{FeatureDesktop, FeatureInput},
- GrantPresentation: "opaque-grant-presentation",
- }
- if err := admission.Validate(); err != nil {
- t.Fatalf("valid admission: %v", err)
- }
-
- admission.RequestedFeatures = []Feature{"session_initiate"}
- if err := admission.Validate(); !errors.Is(err, ErrInvalidAdmission) {
- t.Fatalf("outbound feature error = %v, want invalid admission", err)
- }
-}
-
-func TestTemporaryAuditedSurfacesAreIndependentInventory(t *testing.T) {
- surfaces := TemporaryAuditedSurfaces()
- if len(surfaces) != 3 {
- t.Fatalf("surface count = %d, want 3", len(surfaces))
- }
- for _, surface := range surfaces {
- if surface.Path == "" || surface.Role == "" || surface.Status != temporaryAuditedStatus {
- t.Fatalf("invalid audited surface: %#v", surface)
- }
- }
-
- surfaces[0].Path = "mutated"
- if got := TemporaryAuditedSurfaces()[0].Path; got == "mutated" {
- t.Fatal("audited surface inventory returned a shared slice")
- }
-}
-
-func TestAdapterBoundaryIsInboundOnly(t *testing.T) {
- var _ Adapter = testAdapter{}
-
- adapter := testAdapter{}
- if err := adapter.Descriptor().Validate(); err != nil {
- t.Fatalf("test adapter descriptor: %v", err)
- }
-}
-
-type testAdapter struct{}
-
-func (testAdapter) Descriptor() Descriptor {
- return Descriptor{
- ID: "test-inbound-adapter",
- SpecificationRevision: SpecificationRevision,
- SupportedFeatures: []Feature{FeatureDesktop},
- }
-}
-
-func (testAdapter) ServeInbound(context.Context, InboundTransport, SessionAuthorizer) error {
- return nil
-}
diff --git a/betterdesk-support-agent/internal/interoperability/conformance.go b/betterdesk-support-agent/internal/interoperability/conformance.go
deleted file mode 100644
index 412ae38d..00000000
--- a/betterdesk-support-agent/internal/interoperability/conformance.go
+++ /dev/null
@@ -1,397 +0,0 @@
-package interoperability
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "errors"
- "fmt"
- "strings"
- "sync"
-)
-
-// DefaultMaximumObservedPayloadBytes bounds a single payload considered by the
-// laboratory harness. It is an evidence-capture limit, not a production wire
-// protocol limit.
-const DefaultMaximumObservedPayloadBytes = 4 << 20
-
-// Direction records the observed direction of an opaque wire event.
-type Direction string
-
-const (
- DirectionClientToAgent Direction = "client_to_agent"
- DirectionAgentToClient Direction = "agent_to_client"
-)
-
-// Phase is a BetterDesk-owned semantic label assigned by the lab driver. It
-// deliberately does not encode external message names or schema fields.
-type Phase string
-
-const (
- PhaseTransportOpened Phase = "transport_opened"
- PhaseHandshake Phase = "handshake"
- PhaseAuthentication Phase = "authentication"
- PhaseCapability Phase = "capability"
- PhaseDesktop Phase = "desktop"
- PhaseInput Phase = "input"
- PhaseRejected Phase = "rejected"
- PhaseTransportClosed Phase = "transport_closed"
-)
-
-// VectorKind determines whether an expected payload fingerprint is permitted.
-// Black-box vectors intentionally retain only metadata; synthetic vectors may
-// fingerprint independently generated, non-secret test bytes.
-type VectorKind string
-
-const (
- VectorKindBlackBox VectorKind = "black_box"
- VectorKindSynthetic VectorKind = "synthetic"
-)
-
-// ObservationMode selects how ObserveWire handles the payload supplied by a
-// lab driver. Neither mode retains the payload itself.
-type ObservationMode string
-
-const (
- // ObservationMetadataOnly stores direction, phase, and byte count only.
- // Use it for all stock-client observations, especially authentication and
- // nonce-bearing messages.
- ObservationMetadataOnly ObservationMode = "metadata_only"
- // ObservationSyntheticFingerprint is permitted only for independently
- // generated, non-secret test data. It adds a SHA-256 fingerprint.
- ObservationSyntheticFingerprint ObservationMode = "synthetic_fingerprint"
-)
-
-// ExpectedObservation is one ordered, lossy assertion in a conformance vector.
-// PayloadFingerprint is optional and may be used only by synthetic vectors.
-type ExpectedObservation struct {
- Direction Direction
- Phase Phase
- MinimumBytes int
- MaximumBytes int
- PayloadFingerprint string
-}
-
-// Vector is a BetterDesk-owned test contract for a single observed behavior.
-// It never stores a raw frame, decoded external message, credential, nonce, or
-// copied schema.
-type Vector struct {
- ID string
- SpecificationRevision string
- Kind VectorKind
- Steps []ExpectedObservation
-}
-
-// Validate checks a vector using the default evidence-capture bound.
-func (v Vector) Validate() error {
- return validateVector(v, DefaultMaximumObservedPayloadBytes)
-}
-
-// Observation is a lossy record emitted by a lab driver. Raw payload bytes are
-// intentionally absent so a RunResult can be attached to a release record
-// without retaining client traffic.
-type Observation struct {
- Direction Direction
- Phase Phase
- PayloadBytes int
- PayloadFingerprint string
-}
-
-// ObserveWire creates a bounded, lossy observation and immediately discards
-// the payload reference. Callers must use ObservationMetadataOnly for any
-// stock-client traffic.
-func ObserveWire(direction Direction, phase Phase, payload []byte, mode ObservationMode) (Observation, error) {
- return observeWire(direction, phase, payload, mode, DefaultMaximumObservedPayloadBytes)
-}
-
-// ObservationSink receives metadata-only observations from a laboratory probe.
-// It deliberately offers no method that accepts or returns a persisted raw
-// packet.
-type ObservationSink interface {
- Record(Observation) error
-}
-
-// BlackBoxProbe drives an isolated client-lab scenario. The probe, client
-// binary, credentials, and any temporary packet capture remain outside this
-// package and repository.
-type BlackBoxProbe interface {
- Run(context.Context, Vector, ObservationSink) error
-}
-
-// BlackBoxProbeFunc adapts a function into a BlackBoxProbe.
-type BlackBoxProbeFunc func(context.Context, Vector, ObservationSink) error
-
-// Run implements BlackBoxProbe.
-func (f BlackBoxProbeFunc) Run(ctx context.Context, vector Vector, sink ObservationSink) error {
- if f == nil {
- return ErrNilProbe
- }
- return f(ctx, vector, sink)
-}
-
-// Harness runs one vector against a supplied lab probe. A successful result
-// only means that this vector matched this probe run; it is not a broad
-// compatibility or provenance assertion.
-type Harness struct {
- // MaximumObservedPayloadBytes is optional and defaults to
- // DefaultMaximumObservedPayloadBytes. It applies to both vectors and
- // observations supplied by the probe.
- MaximumObservedPayloadBytes int
-}
-
-// RunResult contains only the vector identifier and lossy observations.
-type RunResult struct {
- VectorID string
- Observations []Observation
-}
-
-// Run validates and executes one vector. It rejects out-of-order events,
-// payloads beyond the configured evidence bound, and accidental fingerprints
-// in a black-box run.
-func (h Harness) Run(ctx context.Context, vector Vector, probe BlackBoxProbe) (RunResult, error) {
- if probe == nil {
- return RunResult{}, ErrNilProbe
- }
- if ctx == nil {
- ctx = context.Background()
- }
-
- maxBytes := h.maximumObservedPayloadBytes()
- checked := cloneVector(vector)
- if err := validateVector(checked, maxBytes); err != nil {
- return RunResult{}, err
- }
-
- recorder := &runRecorder{
- kind: checked.Kind,
- expected: cloneExpectedObservations(checked.Steps),
- maxBytes: maxBytes,
- }
- if err := probe.Run(ctx, cloneVector(checked), recorder); err != nil {
- return recorder.result(checked.ID), err
- }
- if err := recorder.complete(); err != nil {
- return recorder.result(checked.ID), err
- }
- return recorder.result(checked.ID), nil
-}
-
-func (h Harness) maximumObservedPayloadBytes() int {
- if h.MaximumObservedPayloadBytes > 0 {
- return h.MaximumObservedPayloadBytes
- }
- return DefaultMaximumObservedPayloadBytes
-}
-
-type runRecorder struct {
- mu sync.Mutex
-
- kind VectorKind
- expected []ExpectedObservation
- maxBytes int
- actual []Observation
- failure error
-}
-
-func (r *runRecorder) Record(observation Observation) error {
- r.mu.Lock()
- defer r.mu.Unlock()
-
- if r.failure != nil {
- return r.failure
- }
- if err := validateObservation(observation, r.maxBytes); err != nil {
- r.failure = err
- return err
- }
- if r.kind == VectorKindBlackBox && observation.PayloadFingerprint != "" {
- r.failure = fmt.Errorf("%w: black-box observation contains a payload fingerprint", ErrUnsafeObservation)
- return r.failure
- }
-
- index := len(r.actual)
- r.actual = append(r.actual, observation)
- if index >= len(r.expected) {
- r.failure = fmt.Errorf("%w: unexpected %s %s observation", ErrConformanceMismatch, observation.Direction, observation.Phase)
- return r.failure
- }
- if err := r.expected[index].matches(observation); err != nil {
- r.failure = err
- return err
- }
- return nil
-}
-
-func (r *runRecorder) complete() error {
- r.mu.Lock()
- defer r.mu.Unlock()
-
- if r.failure != nil {
- return r.failure
- }
- if len(r.actual) != len(r.expected) {
- return fmt.Errorf("%w: observed %d steps, expected %d", ErrConformanceMismatch, len(r.actual), len(r.expected))
- }
- return nil
-}
-
-func (r *runRecorder) result(vectorID string) RunResult {
- r.mu.Lock()
- defer r.mu.Unlock()
- return RunResult{
- VectorID: vectorID,
- Observations: append([]Observation(nil), r.actual...),
- }
-}
-
-func (step ExpectedObservation) matches(observation Observation) error {
- if step.Direction != observation.Direction || step.Phase != observation.Phase {
- return fmt.Errorf("%w: expected %s %s, got %s %s",
- ErrConformanceMismatch,
- step.Direction,
- step.Phase,
- observation.Direction,
- observation.Phase,
- )
- }
- if observation.PayloadBytes < step.MinimumBytes || observation.PayloadBytes > step.MaximumBytes {
- return fmt.Errorf("%w: %s %s payload size %d is outside [%d,%d]",
- ErrConformanceMismatch,
- step.Direction,
- step.Phase,
- observation.PayloadBytes,
- step.MinimumBytes,
- step.MaximumBytes,
- )
- }
- if step.PayloadFingerprint != "" && step.PayloadFingerprint != observation.PayloadFingerprint {
- return fmt.Errorf("%w: synthetic payload fingerprint mismatch", ErrConformanceMismatch)
- }
- return nil
-}
-
-func observeWire(direction Direction, phase Phase, payload []byte, mode ObservationMode, maxBytes int) (Observation, error) {
- if len(payload) > maxBytes {
- return Observation{}, fmt.Errorf("%w: payload size %d exceeds %d bytes", ErrUnsafeObservation, len(payload), maxBytes)
- }
-
- observation := Observation{
- Direction: direction,
- Phase: phase,
- PayloadBytes: len(payload),
- }
- switch mode {
- case ObservationMetadataOnly:
- case ObservationSyntheticFingerprint:
- sum := sha256.Sum256(payload)
- observation.PayloadFingerprint = hex.EncodeToString(sum[:])
- default:
- return Observation{}, fmt.Errorf("%w: unknown observation mode %q", ErrUnsafeObservation, mode)
- }
- if err := validateObservation(observation, maxBytes); err != nil {
- return Observation{}, err
- }
- return observation, nil
-}
-
-func validateVector(vector Vector, maxBytes int) error {
- if strings.TrimSpace(vector.ID) == "" ||
- strings.TrimSpace(vector.SpecificationRevision) == "" ||
- !knownVectorKind(vector.Kind) ||
- len(vector.Steps) == 0 ||
- maxBytes <= 0 {
- return ErrInvalidVector
- }
-
- for _, step := range vector.Steps {
- if err := validateExpectedObservation(step, maxBytes); err != nil {
- return err
- }
- if vector.Kind == VectorKindBlackBox && step.PayloadFingerprint != "" {
- return fmt.Errorf("%w: black-box vectors cannot pin a payload fingerprint", ErrUnsafeObservation)
- }
- }
- return nil
-}
-
-func validateExpectedObservation(step ExpectedObservation, maxBytes int) error {
- if !knownDirection(step.Direction) ||
- !knownPhase(step.Phase) ||
- step.MinimumBytes < 0 ||
- step.MaximumBytes < step.MinimumBytes ||
- step.MaximumBytes > maxBytes {
- return ErrInvalidVector
- }
- if step.PayloadFingerprint != "" {
- if !validFingerprint(step.PayloadFingerprint) || step.MinimumBytes != step.MaximumBytes {
- return ErrInvalidVector
- }
- }
- return nil
-}
-
-func validateObservation(observation Observation, maxBytes int) error {
- if !knownDirection(observation.Direction) ||
- !knownPhase(observation.Phase) ||
- observation.PayloadBytes < 0 ||
- observation.PayloadBytes > maxBytes ||
- (observation.PayloadFingerprint != "" && !validFingerprint(observation.PayloadFingerprint)) {
- return ErrUnsafeObservation
- }
- return nil
-}
-
-func cloneVector(vector Vector) Vector {
- vector.Steps = cloneExpectedObservations(vector.Steps)
- return vector
-}
-
-func cloneExpectedObservations(steps []ExpectedObservation) []ExpectedObservation {
- return append([]ExpectedObservation(nil), steps...)
-}
-
-func knownDirection(direction Direction) bool {
- return direction == DirectionClientToAgent || direction == DirectionAgentToClient
-}
-
-func knownPhase(phase Phase) bool {
- switch phase {
- case PhaseTransportOpened,
- PhaseHandshake,
- PhaseAuthentication,
- PhaseCapability,
- PhaseDesktop,
- PhaseInput,
- PhaseRejected,
- PhaseTransportClosed:
- return true
- default:
- return false
- }
-}
-
-func knownVectorKind(kind VectorKind) bool {
- return kind == VectorKindBlackBox || kind == VectorKindSynthetic
-}
-
-func validFingerprint(fingerprint string) bool {
- if len(fingerprint) != sha256.Size*2 || fingerprint != strings.ToLower(fingerprint) {
- return false
- }
- _, err := hex.DecodeString(fingerprint)
- return err == nil
-}
-
-var (
- // ErrInvalidVector indicates a vector that cannot be safely used as
- // BetterDesk-owned conformance evidence.
- ErrInvalidVector = errors.New("interoperability: invalid conformance vector")
- // ErrUnsafeObservation indicates an observation that would exceed the
- // evidence boundary or retain unsafe black-box detail.
- ErrUnsafeObservation = errors.New("interoperability: unsafe wire observation")
- // ErrConformanceMismatch indicates a probe result that differs from its
- // declared vector without exposing raw wire payloads.
- ErrConformanceMismatch = errors.New("interoperability: conformance mismatch")
- // ErrNilProbe indicates a harness run without a lab driver.
- ErrNilProbe = errors.New("interoperability: missing black-box probe")
-)
diff --git a/betterdesk-support-agent/internal/interoperability/conformance_test.go b/betterdesk-support-agent/internal/interoperability/conformance_test.go
deleted file mode 100644
index 6bd3d860..00000000
--- a/betterdesk-support-agent/internal/interoperability/conformance_test.go
+++ /dev/null
@@ -1,176 +0,0 @@
-package interoperability
-
-import (
- "context"
- "crypto/sha256"
- "errors"
- "testing"
-)
-
-func TestObserveWireKeepsStockClientObservationsMetadataOnly(t *testing.T) {
- payload := []byte("credential-bearing-test-payload")
- observation, err := ObserveWire(
- DirectionClientToAgent,
- PhaseAuthentication,
- payload,
- ObservationMetadataOnly,
- )
- if err != nil {
- t.Fatalf("observe metadata: %v", err)
- }
- if observation.PayloadBytes != len(payload) {
- t.Fatalf("payload bytes = %d, want %d", observation.PayloadBytes, len(payload))
- }
- if observation.PayloadFingerprint != "" {
- t.Fatalf("metadata-only observation retained fingerprint %q", observation.PayloadFingerprint)
- }
-
- synthetic, err := ObserveWire(
- DirectionAgentToClient,
- PhaseHandshake,
- []byte("independently-generated"),
- ObservationSyntheticFingerprint,
- )
- if err != nil {
- t.Fatalf("observe synthetic: %v", err)
- }
- want := sha256.Sum256([]byte("independently-generated"))
- if synthetic.PayloadFingerprint != stringLowerHex(want[:]) {
- t.Fatalf("synthetic fingerprint = %q, want SHA-256", synthetic.PayloadFingerprint)
- }
-}
-
-func TestHarnessMatchesSyntheticVectorWithoutLeakingPayload(t *testing.T) {
- payload := []byte("synthetic-handshake")
- observed, err := ObserveWire(
- DirectionClientToAgent,
- PhaseHandshake,
- payload,
- ObservationSyntheticFingerprint,
- )
- if err != nil {
- t.Fatal(err)
- }
- vector := Vector{
- ID: "synthetic-inbound-handshake",
- SpecificationRevision: SpecificationRevision,
- Kind: VectorKindSynthetic,
- Steps: []ExpectedObservation{{
- Direction: DirectionClientToAgent,
- Phase: PhaseHandshake,
- MinimumBytes: len(payload),
- MaximumBytes: len(payload),
- PayloadFingerprint: observed.PayloadFingerprint,
- }},
- }
-
- result, err := Harness{MaximumObservedPayloadBytes: 256}.Run(
- context.Background(),
- vector,
- BlackBoxProbeFunc(func(_ context.Context, supplied Vector, sink ObservationSink) error {
- // The probe receives a copy and cannot rewrite the harness's
- // expected sequence after validation.
- supplied.Steps[0].Phase = PhaseInput
- return sink.Record(observed)
- }),
- )
- if err != nil {
- t.Fatalf("run synthetic vector: %v", err)
- }
- if result.VectorID != vector.ID || len(result.Observations) != 1 {
- t.Fatalf("unexpected result: %#v", result)
- }
- if result.Observations[0].PayloadFingerprint != observed.PayloadFingerprint {
- t.Fatal("result did not retain the synthetic test fingerprint")
- }
-}
-
-func TestHarnessRejectsUnsafeOrMismatchedBlackBoxEvidence(t *testing.T) {
- vector := Vector{
- ID: "black-box-auth-rejection",
- SpecificationRevision: SpecificationRevision,
- Kind: VectorKindBlackBox,
- Steps: []ExpectedObservation{{
- Direction: DirectionClientToAgent,
- Phase: PhaseAuthentication,
- MinimumBytes: 1,
- MaximumBytes: 128,
- }},
- }
-
- _, err := Harness{}.Run(context.Background(), vector, BlackBoxProbeFunc(
- func(_ context.Context, _ Vector, sink ObservationSink) error {
- observation, observeErr := ObserveWire(
- DirectionClientToAgent,
- PhaseHandshake,
- []byte{1},
- ObservationMetadataOnly,
- )
- if observeErr != nil {
- return observeErr
- }
- return sink.Record(observation)
- },
- ))
- if !errors.Is(err, ErrConformanceMismatch) {
- t.Fatalf("mismatched phase error = %v, want conformance mismatch", err)
- }
-
- _, err = Harness{}.Run(context.Background(), vector, BlackBoxProbeFunc(
- func(_ context.Context, _ Vector, sink ObservationSink) error {
- observation, observeErr := ObserveWire(
- DirectionClientToAgent,
- PhaseAuthentication,
- []byte{1},
- ObservationSyntheticFingerprint,
- )
- if observeErr != nil {
- return observeErr
- }
- return sink.Record(observation)
- },
- ))
- if !errors.Is(err, ErrUnsafeObservation) {
- t.Fatalf("black-box fingerprint error = %v, want unsafe observation", err)
- }
-}
-
-func TestBlackBoxVectorsCannotPinPayloadFingerprint(t *testing.T) {
- vector := Vector{
- ID: "unsafe-black-box-vector",
- SpecificationRevision: SpecificationRevision,
- Kind: VectorKindBlackBox,
- Steps: []ExpectedObservation{{
- Direction: DirectionClientToAgent,
- Phase: PhaseHandshake,
- MinimumBytes: 1,
- MaximumBytes: 1,
- PayloadFingerprint: stringLowerHex(make([]byte, sha256.Size)),
- }},
- }
- if err := vector.Validate(); !errors.Is(err, ErrUnsafeObservation) {
- t.Fatalf("black-box fingerprint vector error = %v, want unsafe observation", err)
- }
-}
-
-func TestObserveWireRejectsOversizedEvidence(t *testing.T) {
- _, err := ObserveWire(
- DirectionClientToAgent,
- PhaseHandshake,
- make([]byte, DefaultMaximumObservedPayloadBytes+1),
- ObservationMetadataOnly,
- )
- if !errors.Is(err, ErrUnsafeObservation) {
- t.Fatalf("oversized evidence error = %v, want unsafe observation", err)
- }
-}
-
-func stringLowerHex(value []byte) string {
- const hex = "0123456789abcdef"
- result := make([]byte, len(value)*2)
- for index, current := range value {
- result[index*2] = hex[current>>4]
- result[index*2+1] = hex[current&0x0f]
- }
- return string(result)
-}
diff --git a/betterdesk-support-agent/internal/interoperability/doc.go b/betterdesk-support-agent/internal/interoperability/doc.go
deleted file mode 100644
index 125e55d8..00000000
--- a/betterdesk-support-agent/internal/interoperability/doc.go
+++ /dev/null
@@ -1,18 +0,0 @@
-// Package interoperability defines the BetterDesk-owned boundary for inbound
-// desktop-client wire adapters and their black-box conformance evidence.
-//
-// It deliberately has no imports from signalhost, betterdesk-server/codec,
-// betterdesk-server/proto, or any external desktop-client schema. A future
-// adapter implementation receives an already-accepted inbound transport and
-// must delegate admission to SessionAuthorizer before it can use target-side
-// services. The boundary exposes no dialer or controller-side workflow.
-//
-// No independently implemented wire adapter is registered by this package.
-// The existing signalhost and protocol dependencies remain temporary audited
-// compatibility surfaces; TemporaryAuditedSurfaces records their scope without
-// making them dependencies of new code.
-//
-// The conformance harness retains only bounded, lossy wire observations. It is
-// suitable for an isolated stock-client lab, not evidence of complete
-// compatibility or clean-room provenance on its own.
-package interoperability
diff --git a/betterdesk-support-agent/internal/sessioncore/core.go b/betterdesk-support-agent/internal/sessioncore/core.go
deleted file mode 100644
index 58282cf9..00000000
--- a/betterdesk-support-agent/internal/sessioncore/core.go
+++ /dev/null
@@ -1,440 +0,0 @@
-package sessioncore
-
-import (
- "context"
- "errors"
- "fmt"
- "strings"
- "sync"
- "time"
-)
-
-// Core is a concurrency-safe, single-use passive-session state machine.
-// Create a new Core for each enrollment and session attempt.
-type Core struct {
- mu sync.RWMutex
-
- audience string
- deviceID string
- allowedCapabilities []Capability
- verifier GrantVerifier
- clock func() time.Time
- eventSink func(Event)
-
- state State
- operatorID string
- sessionID string
- capabilities []Capability
- events []Event
-}
-
-// New creates a Core in the registered state.
-func New(cfg Config) (*Core, error) {
- if strings.TrimSpace(cfg.Audience) == "" || strings.TrimSpace(cfg.DeviceID) == "" || cfg.Verifier == nil {
- return nil, ErrInvalidConfiguration
- }
-
- allowed, err := validatePassiveCapabilities(cfg.AllowedCapabilities)
- if err != nil {
- return nil, fmt.Errorf("%w: allowed capabilities", ErrInvalidConfiguration)
- }
-
- clock := cfg.Clock
- if clock == nil {
- clock = time.Now
- }
-
- c := &Core{
- audience: cfg.Audience,
- deviceID: cfg.DeviceID,
- allowedCapabilities: allowed,
- verifier: cfg.Verifier,
- clock: clock,
- eventSink: cfg.EventSink,
- state: StateRegistered,
- }
-
- c.mu.Lock()
- event := c.stateEventLocked(EventRegistered, "", StateRegistered, "")
- c.mu.Unlock()
- c.emit(event)
-
- return c, nil
-}
-
-// State returns the current lifecycle state.
-func (c *Core) State() State {
- c.mu.RLock()
- defer c.mu.RUnlock()
- return c.state
-}
-
-// Snapshot returns a copy of the current state without grant presentation data.
-func (c *Core) Snapshot() Snapshot {
- c.mu.RLock()
- defer c.mu.RUnlock()
- return c.snapshotLocked()
-}
-
-// Events returns copies of all non-secret audit events in chronological order.
-func (c *Core) Events() []Event {
- c.mu.RLock()
- defer c.mu.RUnlock()
-
- events := make([]Event, len(c.events))
- for i, event := range c.events {
- events[i] = cloneEvent(event)
- }
- return events
-}
-
-// BeginEnrollment marks this registered device as awaiting server approval.
-func (c *Core) BeginEnrollment() error {
- return c.transition(StateRegistered, StatePending, EventEnrollmentPending)
-}
-
-// ApproveEnrollment records server-side enrollment approval.
-func (c *Core) ApproveEnrollment() error {
- return c.transition(StatePending, StateApproved, EventEnrollmentApproved)
-}
-
-// Authorize verifies a server grant and, on success, records the effective
-// passive capabilities. The opaque grant presentation is never retained.
-func (c *Core) Authorize(ctx context.Context, request AdmissionRequest) (Snapshot, error) {
- if ctx == nil {
- ctx = context.Background()
- }
- request.RequestedCapabilities = cloneCapabilities(request.RequestedCapabilities)
-
- if strings.TrimSpace(request.OperatorID) == "" {
- return c.rejectAuthorization("invalid_operator", ErrGrantOperator)
- }
- if strings.TrimSpace(request.SessionID) == "" || strings.TrimSpace(request.Transport) == "" {
- return c.rejectAuthorization("invalid_session_binding", ErrGrantVerification)
- }
- if _, err := validatePassiveCapabilities(request.RequestedCapabilities); err != nil {
- return c.rejectAuthorization(authorizationReason(err), err)
- }
-
- c.mu.RLock()
- state := c.state
- verifier := c.verifier
- c.mu.RUnlock()
- if state != StateApproved {
- return c.rejectAuthorization("invalid_transition", invalidTransition(state, StateAuthorized))
- }
-
- grant, err := verifier.VerifySessionGrant(ctx, request.GrantPresentation)
- if err != nil {
- // Do not preserve or expose verifier errors: adapters may include
- // presentation details in their diagnostics.
- return c.rejectAuthorization("grant_verification_failed", ErrGrantVerification)
- }
-
- return c.completeAuthorization(request, grant)
-}
-
-// RequestConsent moves an authorized session to the local-consent phase.
-func (c *Core) RequestConsent() error {
- return c.transition(StateAuthorized, StateConsent, EventConsentRequested)
-}
-
-// GrantConsent activates a session after affirmative local consent.
-func (c *Core) GrantConsent() error {
- return c.transition(StateConsent, StateActive, EventConsentGranted)
-}
-
-// DenyConsent terminates a session only while it awaits local consent. It
-// reports false when consent was not pending or the session was already ended.
-func (c *Core) DenyConsent() bool {
- return c.terminate(EventConsentDenied, "consent_denied", StateConsent)
-}
-
-// Disconnect terminates any non-terminal session because the local user chose
-// to disconnect. It is idempotent.
-func (c *Core) Disconnect() bool {
- return c.terminate(EventLocalDisconnected, "local_disconnect", "")
-}
-
-// Cancel terminates any non-terminal session because local cancellation was
-// requested. It is idempotent.
-func (c *Core) Cancel() bool {
- return c.terminate(EventLocalCancellation, "local_cancellation", "")
-}
-
-// BindCancellation terminates the session when ctx is canceled. The returned
-// function stops watching ctx and is safe to call more than once.
-func (c *Core) BindCancellation(ctx context.Context) func() {
- if ctx == nil {
- return func() {}
- }
-
- done := make(chan struct{})
- var once sync.Once
- detach := func() {
- once.Do(func() {
- close(done)
- })
- }
-
- if ctx.Err() != nil {
- c.Cancel()
- return detach
- }
-
- go func() {
- select {
- case <-ctx.Done():
- c.Cancel()
- case <-done:
- }
- }()
-
- return detach
-}
-
-func (c *Core) completeAuthorization(request AdmissionRequest, grant SessionGrant) (Snapshot, error) {
- c.mu.Lock()
- if c.state != StateApproved {
- event := c.stateEventLocked(EventAuthorizationDenied, c.state, c.state, "invalid_transition")
- snapshot := c.snapshotLocked()
- c.mu.Unlock()
- c.emit(event)
- return snapshot, invalidTransition(snapshot.State, StateAuthorized)
- }
-
- effective, err := c.validateGrantLocked(request, grant)
- if err != nil {
- event := c.stateEventLocked(EventAuthorizationDenied, c.state, c.state, authorizationReason(err))
- snapshot := c.snapshotLocked()
- c.mu.Unlock()
- c.emit(event)
- return snapshot, err
- }
-
- from := c.state
- c.state = StateAuthorized
- c.operatorID = request.OperatorID
- c.sessionID = request.SessionID
- c.capabilities = effective
- event := c.stateEventLocked(EventAuthorized, from, StateAuthorized, "")
- snapshot := c.snapshotLocked()
- c.mu.Unlock()
- c.emit(event)
- return snapshot, nil
-}
-
-func (c *Core) validateGrantLocked(request AdmissionRequest, grant SessionGrant) ([]Capability, error) {
- if grant.Audience != c.audience {
- return nil, ErrGrantAudience
- }
- if grant.DeviceID != c.deviceID {
- return nil, ErrGrantDevice
- }
- if grant.OperatorID != request.OperatorID {
- return nil, ErrGrantOperator
- }
- if grant.SessionID != request.SessionID {
- return nil, ErrGrantVerification
- }
- if grant.Transport != request.Transport {
- return nil, ErrGrantVerification
- }
- if !grant.ExpiresAt.After(c.clock()) {
- return nil, ErrGrantExpired
- }
- if grant.Initiator != InitiatorOperator {
- return nil, ErrPassiveOnly
- }
-
- granted, err := validatePassiveCapabilities(grant.Capabilities)
- if err != nil {
- return nil, err
- }
- effective := intersectCapabilities(c.allowedCapabilities, granted, request.RequestedCapabilities)
- if len(effective) == 0 {
- return nil, ErrGrantCapabilities
- }
- return effective, nil
-}
-
-func (c *Core) rejectAuthorization(reason string, err error) (Snapshot, error) {
- c.mu.Lock()
- event := c.stateEventLocked(EventAuthorizationDenied, c.state, c.state, reason)
- snapshot := c.snapshotLocked()
- c.mu.Unlock()
- c.emit(event)
- return snapshot, err
-}
-
-func (c *Core) transition(expected, next State, kind EventKind) error {
- c.mu.Lock()
- if c.state != expected {
- event := c.stateEventLocked(EventTransitionDenied, c.state, c.state, "invalid_transition")
- from := c.state
- c.mu.Unlock()
- c.emit(event)
- return invalidTransition(from, next)
- }
-
- from := c.state
- c.state = next
- event := c.stateEventLocked(kind, from, next, "")
- c.mu.Unlock()
- c.emit(event)
- return nil
-}
-
-func (c *Core) terminate(kind EventKind, reason string, required State) bool {
- c.mu.Lock()
- if c.state == StateTerminated {
- c.mu.Unlock()
- return false
- }
- if required != "" && c.state != required {
- event := c.stateEventLocked(EventTransitionDenied, c.state, c.state, "invalid_transition")
- c.mu.Unlock()
- c.emit(event)
- return false
- }
-
- from := c.state
- c.state = StateTerminated
- event := c.stateEventLocked(kind, from, StateTerminated, reason)
- c.mu.Unlock()
- c.emit(event)
- return true
-}
-
-func (c *Core) snapshotLocked() Snapshot {
- return Snapshot{
- State: c.state,
- DeviceID: c.deviceID,
- OperatorID: c.operatorID,
- SessionID: c.sessionID,
- Capabilities: cloneCapabilities(c.capabilities),
- }
-}
-
-func (c *Core) stateEventLocked(kind EventKind, from, to State, reason string) Event {
- return c.recordLocked(Event{
- Kind: kind,
- From: from,
- To: to,
- DeviceID: c.deviceID,
- OperatorID: c.operatorID,
- SessionID: c.sessionID,
- Capabilities: c.capabilities,
- Reason: reason,
- })
-}
-
-func (c *Core) recordLocked(event Event) Event {
- event.At = c.clock().UTC()
- event.Capabilities = cloneCapabilities(event.Capabilities)
- c.events = append(c.events, event)
- return cloneEvent(event)
-}
-
-func (c *Core) emit(event Event) {
- if c.eventSink != nil {
- c.eventSink(cloneEvent(event))
- }
-}
-
-func validatePassiveCapabilities(capabilities []Capability) ([]Capability, error) {
- if len(capabilities) == 0 {
- return nil, ErrGrantCapabilities
- }
-
- seen := make(map[Capability]struct{}, len(capabilities))
- result := make([]Capability, 0, len(capabilities))
- for _, capability := range capabilities {
- if !isPassiveCapability(capability) {
- return nil, ErrPassiveOnly
- }
- if _, duplicate := seen[capability]; duplicate {
- return nil, ErrGrantCapabilities
- }
- seen[capability] = struct{}{}
- result = append(result, capability)
- }
- return result, nil
-}
-
-func isPassiveCapability(capability Capability) bool {
- switch capability {
- case CapabilityScreenView,
- CapabilityInput,
- CapabilitySystemAudio,
- CapabilityClipboard,
- CapabilityFiles,
- CapabilityTerminal,
- CapabilityChat,
- CapabilityMultiMonitor,
- CapabilityPrivacyMode,
- CapabilityBlockInput,
- CapabilityRestart,
- CapabilityRecording:
- return true
- default:
- return false
- }
-}
-
-func intersectCapabilities(allowed, granted, requested []Capability) []Capability {
- grantedSet := make(map[Capability]struct{}, len(granted))
- for _, capability := range granted {
- grantedSet[capability] = struct{}{}
- }
- requestedSet := make(map[Capability]struct{}, len(requested))
- for _, capability := range requested {
- requestedSet[capability] = struct{}{}
- }
-
- effective := make([]Capability, 0, len(allowed))
- for _, capability := range allowed {
- if _, grantAllows := grantedSet[capability]; !grantAllows {
- continue
- }
- if _, requestedByOperator := requestedSet[capability]; !requestedByOperator {
- continue
- }
- effective = append(effective, capability)
- }
- return effective
-}
-
-func cloneCapabilities(capabilities []Capability) []Capability {
- return append([]Capability(nil), capabilities...)
-}
-
-func cloneEvent(event Event) Event {
- event.Capabilities = cloneCapabilities(event.Capabilities)
- return event
-}
-
-func invalidTransition(from, to State) error {
- return fmt.Errorf("%w: %s -> %s", ErrInvalidTransition, from, to)
-}
-
-func authorizationReason(err error) string {
- switch {
- case errors.Is(err, ErrGrantVerification):
- return "grant_verification_failed"
- case errors.Is(err, ErrGrantAudience):
- return "grant_audience_mismatch"
- case errors.Is(err, ErrGrantDevice):
- return "grant_device_mismatch"
- case errors.Is(err, ErrGrantOperator):
- return "grant_operator_mismatch"
- case errors.Is(err, ErrGrantExpired):
- return "grant_expired"
- case errors.Is(err, ErrPassiveOnly):
- return "passive_policy_violation"
- case errors.Is(err, ErrGrantCapabilities):
- return "grant_capability_mismatch"
- default:
- return "authorization_denied"
- }
-}
diff --git a/betterdesk-support-agent/internal/sessioncore/core_test.go b/betterdesk-support-agent/internal/sessioncore/core_test.go
deleted file mode 100644
index 79bc76bb..00000000
--- a/betterdesk-support-agent/internal/sessioncore/core_test.go
+++ /dev/null
@@ -1,475 +0,0 @@
-package sessioncore
-
-import (
- "context"
- "crypto/ed25519"
- "crypto/rand"
- "encoding/base64"
- "encoding/json"
- "errors"
- "reflect"
- "strings"
- "sync"
- "testing"
- "time"
-)
-
-var fixedNow = time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
-
-func TestLifecycleTransitionsCapabilityIntersectionAndAudit(t *testing.T) {
- const presentation = "opaque-grant-that-must-not-be-audited"
- var verifierPresentation string
- core := newCore(t, GrantVerifierFunc(func(_ context.Context, got string) (SessionGrant, error) {
- verifierPresentation = got
- return validGrant(), nil
- }))
-
- if got := core.State(); got != StateRegistered {
- t.Fatalf("initial state = %q, want %q", got, StateRegistered)
- }
- if err := core.BeginEnrollment(); err != nil {
- t.Fatalf("begin enrollment: %v", err)
- }
- if err := core.ApproveEnrollment(); err != nil {
- t.Fatalf("approve enrollment: %v", err)
- }
-
- snapshot, err := core.Authorize(context.Background(), AdmissionRequest{
- OperatorID: "operator-42",
- SessionID: "session-1",
- Transport: "relay",
- RequestedCapabilities: []Capability{CapabilityScreenView},
- GrantPresentation: presentation,
- })
- if err != nil {
- t.Fatalf("authorize: %v", err)
- }
- if verifierPresentation != presentation {
- t.Fatalf("verifier received %q, want presentation", verifierPresentation)
- }
- if snapshot.State != StateAuthorized {
- t.Fatalf("authorized state = %q, want %q", snapshot.State, StateAuthorized)
- }
- if want := []Capability{CapabilityScreenView}; !reflect.DeepEqual(snapshot.Capabilities, want) {
- t.Fatalf("effective capabilities = %v, want %v", snapshot.Capabilities, want)
- }
-
- if err := core.RequestConsent(); err != nil {
- t.Fatalf("request consent: %v", err)
- }
- if err := core.GrantConsent(); err != nil {
- t.Fatalf("grant consent: %v", err)
- }
- if !core.Disconnect() {
- t.Fatal("first disconnect should terminate the session")
- }
- if core.Disconnect() {
- t.Fatal("second disconnect should be idempotent")
- }
- if got := core.State(); got != StateTerminated {
- t.Fatalf("final state = %q, want %q", got, StateTerminated)
- }
-
- events := core.Events()
- if len(events) != 7 {
- t.Fatalf("events = %d, want 7", len(events))
- }
- last := events[len(events)-1]
- if last.Kind != EventLocalDisconnected || last.From != StateActive || last.To != StateTerminated {
- t.Fatalf("last event = %#v, want active local disconnect", last)
- }
-
- encoded, err := json.Marshal(events)
- if err != nil {
- t.Fatalf("marshal events: %v", err)
- }
- if strings.Contains(string(encoded), presentation) {
- t.Fatal("audit events contain grant presentation")
- }
-}
-
-func TestAuthorizeRejectsInvalidGrants(t *testing.T) {
- tests := []struct {
- name string
- grant SessionGrant
- err error
- reason string
- }{
- {
- name: "wrong audience",
- grant: func() SessionGrant {
- grant := validGrant()
- grant.Audience = "other-audience"
- return grant
- }(),
- err: ErrGrantAudience,
- reason: "grant_audience_mismatch",
- },
- {
- name: "wrong device",
- grant: func() SessionGrant {
- grant := validGrant()
- grant.DeviceID = "other-device"
- return grant
- }(),
- err: ErrGrantDevice,
- reason: "grant_device_mismatch",
- },
- {
- name: "wrong operator",
- grant: func() SessionGrant {
- grant := validGrant()
- grant.OperatorID = "other-operator"
- return grant
- }(),
- err: ErrGrantOperator,
- reason: "grant_operator_mismatch",
- },
- {
- name: "expired",
- grant: func() SessionGrant {
- grant := validGrant()
- grant.ExpiresAt = fixedNow
- return grant
- }(),
- err: ErrGrantExpired,
- reason: "grant_expired",
- },
- {
- name: "non-operator initiator",
- grant: func() SessionGrant {
- grant := validGrant()
- grant.Initiator = SessionInitiator("support_agent")
- return grant
- }(),
- err: ErrPassiveOnly,
- reason: "passive_policy_violation",
- },
- {
- name: "unknown capability",
- grant: func() SessionGrant {
- grant := validGrant()
- grant.Capabilities = []Capability{"session_initiate"}
- return grant
- }(),
- err: ErrPassiveOnly,
- reason: "passive_policy_violation",
- },
- {
- name: "no capability intersection",
- grant: func() SessionGrant {
- grant := validGrant()
- grant.Capabilities = []Capability{CapabilitySystemAudio}
- return grant
- }(),
- err: ErrGrantCapabilities,
- reason: "grant_capability_mismatch",
- },
- }
-
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- core := approvedCore(t, GrantVerifierFunc(func(context.Context, string) (SessionGrant, error) {
- return test.grant, nil
- }))
-
- _, err := core.Authorize(context.Background(), AdmissionRequest{
- OperatorID: "operator-42",
- SessionID: "session-1",
- Transport: "relay",
- RequestedCapabilities: []Capability{CapabilityScreenView},
- GrantPresentation: "opaque-presentation",
- })
- if !errors.Is(err, test.err) {
- t.Fatalf("authorize error = %v, want %v", err, test.err)
- }
- if got := core.State(); got != StateApproved {
- t.Fatalf("state after failed authorization = %q, want %q", got, StateApproved)
- }
-
- events := core.Events()
- last := events[len(events)-1]
- if last.Kind != EventAuthorizationDenied || last.Reason != test.reason {
- t.Fatalf("last event = %#v, want authorization denial %q", last, test.reason)
- }
- })
- }
-}
-
-func TestInboundGrantCanAuthorizeRemoteControlCapabilities(t *testing.T) {
- grant := validGrant()
- grant.Capabilities = []Capability{CapabilityScreenView, CapabilityInput, CapabilityFiles}
- core, err := New(Config{
- Audience: "betterdesk-support",
- DeviceID: "BD-DEVICE-1",
- AllowedCapabilities: []Capability{CapabilityScreenView, CapabilityInput, CapabilityFiles},
- Verifier: GrantVerifierFunc(func(context.Context, string) (SessionGrant, error) {
- return grant, nil
- }),
- Clock: func() time.Time { return fixedNow },
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := core.BeginEnrollment(); err != nil {
- t.Fatal(err)
- }
- if err := core.ApproveEnrollment(); err != nil {
- t.Fatal(err)
- }
- snapshot, err := core.Authorize(context.Background(), AdmissionRequest{
- OperatorID: "operator-42",
- SessionID: "session-1",
- Transport: "relay",
- RequestedCapabilities: []Capability{CapabilityScreenView, CapabilityInput, CapabilityFiles},
- GrantPresentation: "opaque-presentation",
- })
- if err != nil {
- t.Fatal(err)
- }
- want := []Capability{CapabilityScreenView, CapabilityInput, CapabilityFiles}
- if !reflect.DeepEqual(snapshot.Capabilities, want) {
- t.Fatalf("effective capabilities = %v, want %v", snapshot.Capabilities, want)
- }
-}
-
-func TestEd25519GrantVerifierRejectsAlteredOrOutboundGrants(t *testing.T) {
- publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
- if err != nil {
- t.Fatal(err)
- }
- verifier, err := NewEd25519GrantVerifier(base64.StdEncoding.EncodeToString(publicKey))
- if err != nil {
- t.Fatal(err)
- }
- verifier.now = func() time.Time { return fixedNow }
-
- claims := signedGrantClaims{
- Version: 1,
- Audience: "betterdesk-support",
- DeviceID: "BD-DEVICE-1",
- OperatorID: "operator-42",
- SessionID: "session-1",
- Transport: "relay",
- Initiator: "operator",
- Capabilities: []string{"screen_view", "input"},
- IssuedAt: fixedNow.Unix(),
- ExpiresAt: fixedNow.Add(time.Minute).Unix(),
- }
- token := signTestGrant(t, privateKey, claims)
- grant, err := verifier.VerifySessionGrant(context.Background(), token)
- if err != nil {
- t.Fatal(err)
- }
- if !reflect.DeepEqual(grant.Capabilities, []Capability{CapabilityScreenView, CapabilityInput}) {
- t.Fatalf("decoded capabilities = %#v", grant.Capabilities)
- }
-
- claims.Initiator = "support_agent"
- if _, err := verifier.VerifySessionGrant(context.Background(), signTestGrant(t, privateKey, claims)); !errors.Is(err, ErrGrantVerification) {
- t.Fatalf("outbound initiator error = %v, want verification error", err)
- }
-
- parts := strings.Split(token, ".")
- replacement := byte('A')
- if parts[1][0] == replacement {
- replacement = 'B'
- }
- parts[1] = string(replacement) + parts[1][1:]
- tampered := strings.Join(parts, ".")
- if _, err := verifier.VerifySessionGrant(context.Background(), tampered); !errors.Is(err, ErrGrantVerification) {
- t.Fatalf("tampered grant error = %v, want verification error", err)
- }
-}
-
-func TestAuthorizeDoesNotExposeVerifierDetails(t *testing.T) {
- const secret = "grant-presentation-must-not-leak"
- core := approvedCore(t, GrantVerifierFunc(func(context.Context, string) (SessionGrant, error) {
- return SessionGrant{}, errors.New(secret)
- }))
-
- _, err := core.Authorize(context.Background(), AdmissionRequest{
- OperatorID: "operator-42",
- SessionID: "session-1",
- Transport: "relay",
- RequestedCapabilities: []Capability{CapabilityScreenView},
- GrantPresentation: secret,
- })
- if !errors.Is(err, ErrGrantVerification) {
- t.Fatalf("authorize error = %v, want %v", err, ErrGrantVerification)
- }
- if strings.Contains(err.Error(), secret) {
- t.Fatal("authorization error exposes verifier detail")
- }
-
- encoded, marshalErr := json.Marshal(core.Events())
- if marshalErr != nil {
- t.Fatalf("marshal events: %v", marshalErr)
- }
- if strings.Contains(string(encoded), secret) {
- t.Fatal("audit events expose verifier detail or presentation")
- }
-}
-
-func TestStateMachineRejectsSkippedTransitions(t *testing.T) {
- core := newCore(t, GrantVerifierFunc(func(context.Context, string) (SessionGrant, error) {
- return validGrant(), nil
- }))
-
- if err := core.ApproveEnrollment(); !errors.Is(err, ErrInvalidTransition) {
- t.Fatalf("approve before pending error = %v, want %v", err, ErrInvalidTransition)
- }
- if _, err := core.Authorize(context.Background(), AdmissionRequest{
- OperatorID: "operator-42",
- SessionID: "session-1",
- Transport: "relay",
- RequestedCapabilities: []Capability{CapabilityScreenView},
- GrantPresentation: "opaque-presentation",
- }); !errors.Is(err, ErrInvalidTransition) {
- t.Fatalf("authorize before approval error = %v, want %v", err, ErrInvalidTransition)
- }
-}
-
-func TestDisconnectIsConcurrencySafeAndIdempotent(t *testing.T) {
- core := activeCore(t)
-
- const callers = 32
- results := make(chan bool, callers)
- start := make(chan struct{})
- var wait sync.WaitGroup
- for range callers {
- wait.Add(1)
- go func() {
- defer wait.Done()
- <-start
- results <- core.Disconnect()
- }()
- }
- close(start)
- wait.Wait()
- close(results)
-
- terminated := 0
- for result := range results {
- if result {
- terminated++
- }
- }
- if terminated != 1 {
- t.Fatalf("successful disconnects = %d, want 1", terminated)
- }
- if got := core.State(); got != StateTerminated {
- t.Fatalf("state = %q, want %q", got, StateTerminated)
- }
-
- disconnectEvents := 0
- for _, event := range core.Events() {
- if event.Kind == EventLocalDisconnected {
- disconnectEvents++
- }
- }
- if disconnectEvents != 1 {
- t.Fatalf("disconnect events = %d, want 1", disconnectEvents)
- }
-}
-
-func TestBindCancellationTerminatesActiveSession(t *testing.T) {
- core := activeCore(t)
- ctx, cancel := context.WithCancel(context.Background())
- detach := core.BindCancellation(ctx)
- defer detach()
-
- cancel()
- deadline := time.Now().Add(time.Second)
- for core.State() != StateTerminated && time.Now().Before(deadline) {
- time.Sleep(time.Millisecond)
- }
- if got := core.State(); got != StateTerminated {
- t.Fatalf("state after cancellation = %q, want %q", got, StateTerminated)
- }
-
- events := core.Events()
- last := events[len(events)-1]
- if last.Kind != EventLocalCancellation || last.Reason != "local_cancellation" {
- t.Fatalf("last event = %#v, want local cancellation", last)
- }
-}
-
-func newCore(t *testing.T, verifier GrantVerifier) *Core {
- t.Helper()
- core, err := New(Config{
- Audience: "betterdesk-support",
- DeviceID: "BD-DEVICE-1",
- AllowedCapabilities: []Capability{CapabilityScreenView, CapabilitySystemAudio},
- Verifier: verifier,
- Clock: func() time.Time {
- return fixedNow
- },
- })
- if err != nil {
- t.Fatalf("new core: %v", err)
- }
- return core
-}
-
-func approvedCore(t *testing.T, verifier GrantVerifier) *Core {
- t.Helper()
- core := newCore(t, verifier)
- if err := core.BeginEnrollment(); err != nil {
- t.Fatalf("begin enrollment: %v", err)
- }
- if err := core.ApproveEnrollment(); err != nil {
- t.Fatalf("approve enrollment: %v", err)
- }
- return core
-}
-
-func activeCore(t *testing.T) *Core {
- t.Helper()
- core := approvedCore(t, GrantVerifierFunc(func(context.Context, string) (SessionGrant, error) {
- return validGrant(), nil
- }))
- if _, err := core.Authorize(context.Background(), AdmissionRequest{
- OperatorID: "operator-42",
- SessionID: "session-1",
- Transport: "relay",
- RequestedCapabilities: []Capability{CapabilityScreenView},
- GrantPresentation: "opaque-presentation",
- }); err != nil {
- t.Fatalf("authorize: %v", err)
- }
- if err := core.RequestConsent(); err != nil {
- t.Fatalf("request consent: %v", err)
- }
- if err := core.GrantConsent(); err != nil {
- t.Fatalf("grant consent: %v", err)
- }
- return core
-}
-
-func validGrant() SessionGrant {
- return SessionGrant{
- Audience: "betterdesk-support",
- DeviceID: "BD-DEVICE-1",
- OperatorID: "operator-42",
- SessionID: "session-1",
- Transport: "relay",
- Capabilities: []Capability{CapabilityScreenView, CapabilitySystemAudio},
- ExpiresAt: fixedNow.Add(time.Minute),
- Initiator: InitiatorOperator,
- }
-}
-
-func signTestGrant(t *testing.T, privateKey ed25519.PrivateKey, claims signedGrantClaims) string {
- t.Helper()
- payload, err := json.Marshal(claims)
- if err != nil {
- t.Fatal(err)
- }
- signature := ed25519.Sign(privateKey, payload)
- return strings.Join([]string{
- signedGrantVersion,
- base64.RawURLEncoding.EncodeToString(payload),
- base64.RawURLEncoding.EncodeToString(signature),
- }, ".")
-}
diff --git a/betterdesk-support-agent/internal/sessioncore/doc.go b/betterdesk-support-agent/internal/sessioncore/doc.go
deleted file mode 100644
index b07b0f82..00000000
--- a/betterdesk-support-agent/internal/sessioncore/doc.go
+++ /dev/null
@@ -1,7 +0,0 @@
-// Package sessioncore provides the transport-agnostic, passive-session
-// lifecycle used by BetterDesk support targets.
-//
-// A Core represents one enrollment and support-session attempt. It intentionally
-// owns policy and state only: callers supply server-grant verification and wire
-// approved sessions to their transport separately.
-package sessioncore
diff --git a/betterdesk-support-agent/internal/sessioncore/signed_grant.go b/betterdesk-support-agent/internal/sessioncore/signed_grant.go
deleted file mode 100644
index 46af69aa..00000000
--- a/betterdesk-support-agent/internal/sessioncore/signed_grant.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package sessioncore
-
-import (
- "context"
- "crypto/ed25519"
- "encoding/base64"
- "encoding/json"
- "fmt"
- "strings"
- "time"
-)
-
-const signedGrantVersion = "v1"
-const maxGrantLifetime = 10 * time.Minute
-
-// Ed25519GrantVerifier validates the compact signed grant envelope emitted by
-// BetterDesk Server. It is implemented in the Support Agent without importing
-// server packages, so the native session core retains a narrow, auditable
-// verification boundary.
-type Ed25519GrantVerifier struct {
- publicKey ed25519.PublicKey
- now func() time.Time
-}
-
-// NewEd25519GrantVerifier parses a base64 Ed25519 public key. Both padded and
-// unpadded standard base64 are accepted for operational copy/paste safety.
-func NewEd25519GrantVerifier(encodedPublicKey string) (*Ed25519GrantVerifier, error) {
- raw, err := decodeEd25519PublicKey(encodedPublicKey)
- if err != nil {
- return nil, err
- }
- return &Ed25519GrantVerifier{publicKey: ed25519.PublicKey(raw), now: time.Now}, nil
-}
-
-// VerifySessionGrant implements GrantVerifier.
-func (v *Ed25519GrantVerifier) VerifySessionGrant(_ context.Context, presentation string) (SessionGrant, error) {
- if v == nil || len(v.publicKey) != ed25519.PublicKeySize {
- return SessionGrant{}, ErrGrantVerification
- }
- parts := strings.Split(presentation, ".")
- if len(parts) != 3 || parts[0] != signedGrantVersion {
- return SessionGrant{}, ErrGrantVerification
- }
- payload, err := base64.RawURLEncoding.DecodeString(parts[1])
- if err != nil {
- return SessionGrant{}, ErrGrantVerification
- }
- signature, err := base64.RawURLEncoding.DecodeString(parts[2])
- if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(v.publicKey, payload, signature) {
- return SessionGrant{}, ErrGrantVerification
- }
-
- var claims signedGrantClaims
- if err := json.Unmarshal(payload, &claims); err != nil {
- return SessionGrant{}, ErrGrantVerification
- }
- if claims.Version != 1 || strings.TrimSpace(claims.Audience) == "" ||
- strings.TrimSpace(claims.DeviceID) == "" || strings.TrimSpace(claims.OperatorID) == "" ||
- strings.TrimSpace(claims.SessionID) == "" || strings.TrimSpace(claims.Transport) == "" ||
- strings.TrimSpace(claims.Initiator) != string(InitiatorOperator) {
- return SessionGrant{}, ErrGrantVerification
- }
- now := v.now().UTC().Unix()
- if claims.IssuedAt <= 0 || claims.ExpiresAt <= claims.IssuedAt ||
- claims.IssuedAt > now+30 || claims.ExpiresAt-claims.IssuedAt > int64(maxGrantLifetime/time.Second) {
- return SessionGrant{}, ErrGrantVerification
- }
- if claims.ExpiresAt <= now {
- return SessionGrant{}, ErrGrantExpired
- }
- capabilities := make([]Capability, 0, len(claims.Capabilities))
- for _, capability := range claims.Capabilities {
- capabilities = append(capabilities, Capability(strings.TrimSpace(strings.ToLower(capability))))
- }
- if _, err := validatePassiveCapabilities(capabilities); err != nil {
- return SessionGrant{}, err
- }
- return SessionGrant{
- Audience: strings.TrimSpace(claims.Audience),
- DeviceID: strings.TrimSpace(claims.DeviceID),
- OperatorID: strings.TrimSpace(claims.OperatorID),
- SessionID: strings.TrimSpace(claims.SessionID),
- Transport: strings.TrimSpace(strings.ToLower(claims.Transport)),
- Capabilities: capabilities,
- ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC(),
- Initiator: InitiatorOperator,
- }, nil
-}
-
-type signedGrantClaims struct {
- Version int `json:"v"`
- Audience string `json:"aud"`
- DeviceID string `json:"device_id"`
- OperatorID string `json:"operator_id"`
- SessionID string `json:"session_id"`
- Transport string `json:"transport"`
- Initiator string `json:"initiator"`
- Capabilities []string `json:"capabilities"`
- IssuedAt int64 `json:"iat"`
- ExpiresAt int64 `json:"exp"`
-}
-
-func decodeEd25519PublicKey(encoded string) ([]byte, error) {
- encoded = strings.TrimSpace(encoded)
- for _, decoder := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding} {
- raw, err := decoder.DecodeString(encoded)
- if err == nil && len(raw) == ed25519.PublicKeySize {
- return raw, nil
- }
- }
- return nil, fmt.Errorf("invalid session-grant Ed25519 public key")
-}
diff --git a/betterdesk-support-agent/internal/sessioncore/types.go b/betterdesk-support-agent/internal/sessioncore/types.go
deleted file mode 100644
index 4a41c489..00000000
--- a/betterdesk-support-agent/internal/sessioncore/types.go
+++ /dev/null
@@ -1,186 +0,0 @@
-package sessioncore
-
-import (
- "context"
- "errors"
- "time"
-)
-
-// State is the current phase of a passive BetterDesk support session.
-type State string
-
-const (
- // StateRegistered is the initial state for a locally known device.
- StateRegistered State = "registered"
- // StatePending means the device is awaiting server-side enrollment approval.
- StatePending State = "pending"
- // StateApproved means device enrollment has been approved.
- StateApproved State = "approved"
- // StateAuthorized means a verified server grant authorized an operator.
- StateAuthorized State = "authorized"
- // StateConsent means the locally present user must decide whether to continue.
- StateConsent State = "consent"
- // StateActive means the passive session may be connected to its transport.
- StateActive State = "active"
- // StateTerminated is final for a Core instance.
- StateTerminated State = "terminated"
-)
-
-// Capability describes an operation allowed in a passive support session.
-// "Passive" constrains who may initiate the session; it does not turn an
-// approved remote-support session into a view-only stream.
-type Capability string
-
-const (
- // CapabilityScreenView allows the device display to be sent to the operator.
- CapabilityScreenView Capability = "screen_view"
- // CapabilityInput allows the approved operator to inject desktop input.
- CapabilityInput Capability = "input"
- // CapabilitySystemAudio allows device audio to be sent to the operator.
- CapabilitySystemAudio Capability = "system_audio"
- // CapabilityClipboard allows clipboard synchronization for the session.
- CapabilityClipboard Capability = "clipboard"
- // CapabilityFiles allows file transfer subject to the local file policy.
- CapabilityFiles Capability = "files"
- // CapabilityTerminal allows terminal sessions subject to local consent.
- CapabilityTerminal Capability = "terminal"
- // CapabilityChat allows session chat.
- CapabilityChat Capability = "chat"
- // CapabilityMultiMonitor allows display selection.
- CapabilityMultiMonitor Capability = "multi_monitor"
- // CapabilityPrivacyMode enables only a platform-supported privacy mode.
- CapabilityPrivacyMode Capability = "privacy_mode"
- // CapabilityBlockInput enables only a platform-supported local-input block.
- CapabilityBlockInput Capability = "block_input"
- // CapabilityRestart permits a policy-controlled restart request.
- CapabilityRestart Capability = "restart"
- // CapabilityRecording permits policy-controlled recording.
- CapabilityRecording Capability = "recording"
-)
-
-// SessionInitiator identifies the party permitted to create a session. It
-// says nothing about approved input or response traffic inside that session.
-type SessionInitiator string
-
-const (
- // InitiatorOperator means an operator/controller connected to the passive
- // Support Agent. Support Agent itself may never be the initiator.
- InitiatorOperator SessionInitiator = "operator"
-)
-
-// SessionGrant is the verified, non-secret claim set from a BetterDesk server.
-// Grant presentation material is intentionally not retained in this type.
-type SessionGrant struct {
- Audience string
- DeviceID string
- OperatorID string
- SessionID string
- Transport string
- Capabilities []Capability
- ExpiresAt time.Time
- Initiator SessionInitiator
-}
-
-// AdmissionRequest is an operator's request to begin a passive session.
-// GrantPresentation is opaque to Core, passed only to GrantVerifier, and never
-// stored or included in an Event.
-type AdmissionRequest struct {
- OperatorID string
- SessionID string
- Transport string
- RequestedCapabilities []Capability
- GrantPresentation string
-}
-
-// GrantVerifier validates a server-issued grant and returns its verified claims.
-// Core then binds those claims to its configured audience and device, the
-// requesting operator, passive capabilities, and the current time.
-type GrantVerifier interface {
- VerifySessionGrant(context.Context, string) (SessionGrant, error)
-}
-
-// GrantVerifierFunc adapts a function into a GrantVerifier.
-type GrantVerifierFunc func(context.Context, string) (SessionGrant, error)
-
-// VerifySessionGrant implements GrantVerifier.
-func (f GrantVerifierFunc) VerifySessionGrant(ctx context.Context, presentation string) (SessionGrant, error) {
- if f == nil {
- return SessionGrant{}, ErrGrantVerification
- }
- return f(ctx, presentation)
-}
-
-// EventKind identifies a non-secret audit event emitted by Core.
-type EventKind string
-
-const (
- EventRegistered EventKind = "registered"
- EventEnrollmentPending EventKind = "enrollment_pending"
- EventEnrollmentApproved EventKind = "enrollment_approved"
- EventAuthorized EventKind = "authorized"
- EventAuthorizationDenied EventKind = "authorization_denied"
- EventConsentRequested EventKind = "consent_requested"
- EventConsentGranted EventKind = "consent_granted"
- EventConsentDenied EventKind = "consent_denied"
- EventLocalDisconnected EventKind = "local_disconnected"
- EventLocalCancellation EventKind = "local_cancellation"
- EventTransitionDenied EventKind = "transition_denied"
-)
-
-// Event is an auditable state-machine record. It deliberately contains no
-// grant presentation, credential, token, or verifier error payload.
-type Event struct {
- At time.Time
- Kind EventKind
- From State
- To State
- DeviceID string
- OperatorID string
- SessionID string
- Capabilities []Capability
- Reason string
-}
-
-// Snapshot is a concurrency-safe copy of the current public session state.
-type Snapshot struct {
- State State
- DeviceID string
- OperatorID string
- SessionID string
- Capabilities []Capability
-}
-
-// Config binds a Core to one BetterDesk device and audience.
-type Config struct {
- Audience string
- DeviceID string
- AllowedCapabilities []Capability
- Verifier GrantVerifier
-
- // Clock is optional and defaults to time.Now.
- Clock func() time.Time
- // EventSink is optional. It receives copies of events after Core records them.
- EventSink func(Event)
-}
-
-var (
- // ErrInvalidConfiguration indicates a Core configuration that cannot enforce
- // passive-session policy.
- ErrInvalidConfiguration = errors.New("sessioncore: invalid configuration")
- // ErrInvalidTransition indicates an action that is not valid for the current state.
- ErrInvalidTransition = errors.New("sessioncore: invalid state transition")
- // ErrGrantVerification indicates that the injected verifier rejected a grant.
- ErrGrantVerification = errors.New("sessioncore: grant verification failed")
- // ErrGrantAudience indicates a grant for another BetterDesk audience.
- ErrGrantAudience = errors.New("sessioncore: grant audience mismatch")
- // ErrGrantDevice indicates a grant for another device.
- ErrGrantDevice = errors.New("sessioncore: grant device mismatch")
- // ErrGrantOperator indicates a grant that is not bound to the requesting operator.
- ErrGrantOperator = errors.New("sessioncore: grant operator mismatch")
- // ErrGrantExpired indicates an expired or otherwise unusable grant.
- ErrGrantExpired = errors.New("sessioncore: grant expired")
- // ErrGrantCapabilities indicates a grant or request with no permitted overlap.
- ErrGrantCapabilities = errors.New("sessioncore: grant capability mismatch")
- // ErrPassiveOnly indicates a direction or capability that violates passive mode.
- ErrPassiveOnly = errors.New("sessioncore: passive sessions are one-way only")
-)
diff --git a/betterdesk-support-agent/internal/totp/totp.go b/betterdesk-support-agent/internal/totp/totp.go
deleted file mode 100644
index 60055126..00000000
--- a/betterdesk-support-agent/internal/totp/totp.go
+++ /dev/null
@@ -1,63 +0,0 @@
-// Package totp implements the small RFC 6238 verifier needed by the Support
-// Agent. It intentionally has no dependency on BetterDesk server packages.
-package totp
-
-import (
- "crypto/hmac"
- "crypto/sha1" // RFC 6238's required SHA-1 interoperability profile.
- "crypto/subtle"
- "encoding/base32"
- "encoding/binary"
- "fmt"
- "strings"
- "time"
-)
-
-const (
- digits = 6
- period = 30 * time.Second
-)
-
-// Validate accepts a six-digit TOTP code in the current 30-second window,
-// with one adjacent window tolerated for normal clock drift.
-func Validate(secret, code string, now time.Time) bool {
- if len(code) != digits {
- return false
- }
- for offset := -1; offset <= 1; offset++ {
- expected, err := codeAt(secret, now.Add(time.Duration(offset)*period))
- if err != nil {
- continue
- }
- if subtle.ConstantTimeCompare([]byte(expected), []byte(code)) == 1 {
- return true
- }
- }
- return false
-}
-
-func codeAt(secret string, at time.Time) (string, error) {
- key, err := decodeSecret(secret)
- if err != nil {
- return "", err
- }
- var counter [8]byte
- binary.BigEndian.PutUint64(counter[:], uint64(at.Unix()/int64(period/time.Second)))
- mac := hmac.New(sha1.New, key)
- _, _ = mac.Write(counter[:])
- sum := mac.Sum(nil)
- offset := int(sum[len(sum)-1] & 0x0f)
- if offset+4 > len(sum) {
- return "", fmt.Errorf("invalid TOTP digest")
- }
- value := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff
- return fmt.Sprintf("%06d", value%1_000_000), nil
-}
-
-func decodeSecret(secret string) ([]byte, error) {
- normalized := strings.TrimRight(strings.ToUpper(strings.TrimSpace(secret)), "=")
- if normalized == "" {
- return nil, fmt.Errorf("empty TOTP secret")
- }
- return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(normalized)
-}
diff --git a/betterdesk-support-agent/internal/totp/totp_test.go b/betterdesk-support-agent/internal/totp/totp_test.go
deleted file mode 100644
index 6913078f..00000000
--- a/betterdesk-support-agent/internal/totp/totp_test.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package totp
-
-import (
- "testing"
- "time"
-)
-
-func TestValidateRFC6238SHA1SixDigitProfile(t *testing.T) {
- // RFC 6238 Appendix B: SHA-1 secret at T=59 produces 94287082. The
- // BetterDesk Support Agent uses its last six digits, 287082.
- const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
- at := time.Unix(59, 0)
- if !Validate(secret, "287082", at) {
- t.Fatal("valid RFC 6238 code was rejected")
- }
- if Validate(secret, "287083", at) {
- t.Fatal("invalid code was accepted")
- }
-}
diff --git a/betterdesk-support-agent/locale_detect_unix.go b/betterdesk-support-agent/locale_detect_unix.go
deleted file mode 100644
index 9448593a..00000000
--- a/betterdesk-support-agent/locale_detect_unix.go
+++ /dev/null
@@ -1,30 +0,0 @@
-//go:build !windows
-
-package main
-
-import (
- "os"
- "strings"
-)
-
-func detectSystemLanguage() string {
- for _, key := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
- if v := os.Getenv(key); v != "" {
- if code := parseUnixLocale(v); code != "" {
- return code
- }
- }
- }
- return ""
-}
-
-func parseUnixLocale(v string) string {
- v = strings.TrimSpace(v)
- if v == "" || v == "C" || v == "POSIX" {
- return ""
- }
- if i := strings.Index(v, "."); i >= 0 {
- v = v[:i]
- }
- return normalizeLocale(v)
-}
diff --git a/betterdesk-support-agent/locale_detect_windows.go b/betterdesk-support-agent/locale_detect_windows.go
deleted file mode 100644
index 7d33a78c..00000000
--- a/betterdesk-support-agent/locale_detect_windows.go
+++ /dev/null
@@ -1,26 +0,0 @@
-//go:build windows
-
-package main
-
-import (
- "syscall"
- "unsafe"
-)
-
-var (
- kernel32 = syscall.NewLazyDLL("kernel32.dll")
- procGetUserDefaultLocaleName = kernel32.NewProc("GetUserDefaultLocaleName")
-)
-
-func detectSystemLanguage() string {
- buf := make([]uint16, 85)
- r, _, _ := procGetUserDefaultLocaleName.Call(
- uintptr(unsafe.Pointer(&buf[0])),
- uintptr(len(buf)),
- )
- if r == 0 {
- return ""
- }
- tag := syscall.UTF16ToString(buf)
- return normalizeLocale(tag)
-}
diff --git a/betterdesk-support-agent/locales/ar.json b/betterdesk-support-agent/locales/ar.json
deleted file mode 100644
index c98c813f..00000000
--- a/betterdesk-support-agent/locales/ar.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "الدعم",
- "your_id": "هويتك",
- "access_password": "كلمة المرور",
- "show": "إظهار",
- "hide": "إخفاء",
- "copy": "نسخ",
- "copied": "تم النسخ إلى الحافظة",
- "regenerate": "إنشاء جديد",
- "password_regenerated": "تم إنشاء كلمة مرور جديدة",
- "set_custom": "تعيين كلمة مرور مخصصة",
- "custom_password": "كلمة مرور مخصصة",
- "access_mode": "وضع الوصول",
- "mode_supervised": "السؤال في كل مرة",
- "mode_unattended": "وصول بدون إشراف",
- "mode_disabled": "معطّل",
- "receive_support": "الحصول على الدعم",
- "receive_support_hint": "اطلب المساعدة أو دردش مع الدعم. يمكنك أيضًا مشاركة المعرف وكلمة المرور أدناه.",
- "or_share_id": "أو شارك معرّفك",
- "share_id_password": "مشاركة المعرف وكلمة المرور",
- "share_id_hint": "شارك هذه البيانات حتى يتمكن الدعم من الاتصال بجهازك.",
- "ongoing_session": "جلسة جارية",
- "close_session": "إغلاق الجلسة",
- "request_help": "طلب المساعدة",
- "help_message": "صف مشكلتك",
- "send": "يرسل",
- "cancel": "يلغي",
- "help_sent": "تم إرسال طلب المساعدة",
- "help_failed": "تعذّر إرسال طلب المساعدة",
- "connected": "متصل",
- "disconnected": "جارٍ الاتصال...",
- "save": "يحفظ",
- "settings": "إعدادات",
- "test_connection": "اختبار الاتصال",
- "test_running": "جارٍ اختبار الاتصال…",
- "test_ok": "الاتصال سليم",
- "test_failed": "مشكلة في الاتصال",
- "test_gateway": "بوابة بعيدة",
- "test_api": "واجهة الإدارة",
- "test_enrollment": "واجهة تسجيل الجهاز",
- "close": "يغلق",
- "password_too_short": "يجب أن تتكون كلمة المرور من 6 أحرف على الأقل",
- "status_ready": "جاهز للاتصال (اتصال آمن)",
- "enrollment_pending": "قيد الانتظار",
- "enrollment_rejected": "مرفوض",
- "enrollment_error": "فشل التسجيل",
- "consent_title": "معلومات الخبير",
- "consent_prompt": "السماح بالاتصال من",
- "consent_ack": "لقد قرأت معلومات الخبير.",
- "consent_display_name": "الاسم المعروض",
- "consent_session": "الجلسة",
- "session_active": "جلسة نشطة",
- "session_with": "جلسة مع",
- "session_disconnect": "إخفاء شريط الجلسة",
- "disconnect": "قطع الاتصال",
- "chat_with_support": "الدردشة مع الدعم",
- "settings_language": "لغة",
- "quit": "إنهاء",
- "consent_accept": "متابعة",
- "consent_deny": "إلغاء الجلسة",
- "chat_title": "محادثة",
- "chat_send": "يرسل",
- "chat_placeholder": "اكتب رسالة...",
- "chat_empty": "لا توجد رسائل حتى الآن",
- "totp_title": "المصادقة الثنائية",
- "totp_enabled": "تم تفعيل 2FA على هذا الجهاز",
- "totp_disabled": "2FA غير مفعّل",
- "totp_setup": "إعداد 2FA",
- "totp_disable": "تعطيل 2FA",
- "totp_manual_key": "مفتاح يدوي",
- "totp_step2": "امسح URI في تطبيق المصادقة ثم أدخل الرمز",
- "totp_enter_code": "أدخل رمز التحقق",
- "totp_verify_enable": "تحقق وفعّل",
- "totp_invalid_code": "رمز تحقق غير صالح",
- "totp_enabled_success": "تم تفعيل المصادقة الثنائية",
- "totp_disabled_success": "تم تعطيل المصادقة الثنائية",
- "totp_setup_failed": "تعذّر بدء إعداد 2FA",
- "totp_required": "المصادقة الثنائية مطلوبة",
- "totp_wrong_code": "رمز 2FA غير صالح"
-}
diff --git a/betterdesk-support-agent/locales/cs.json b/betterdesk-support-agent/locales/cs.json
deleted file mode 100644
index 17d3a905..00000000
--- a/betterdesk-support-agent/locales/cs.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Podpora",
- "your_id": "Vaše ID",
- "access_password": "Heslo",
- "show": "Zobrazit",
- "hide": "Skrýt",
- "copy": "Kopírovat",
- "copied": "Zkopírováno do schránky",
- "regenerate": "Generovat nové",
- "password_regenerated": "Bylo vygenerováno nové heslo",
- "set_custom": "Nastavit vlastní heslo",
- "custom_password": "Vlastní heslo",
- "access_mode": "Režim přístupu",
- "mode_supervised": "Ptát se pokaždé",
- "mode_unattended": "Bez dozoru",
- "mode_disabled": "Zakázáno",
- "receive_support": "Získat podporu",
- "receive_support_hint": "Požádejte o pomoc nebo chatujte s podporou. Níže můžete také sdílet své ID a heslo.",
- "or_share_id": "Nebo sdílejte své ID",
- "share_id_password": "Sdílet ID a heslo",
- "share_id_hint": "Sdílejte tyto údaje, aby se podpora mohla připojit k vašemu zařízení.",
- "ongoing_session": "Probíhající relace",
- "close_session": "Ukončit relaci",
- "request_help": "Požádat o pomoc",
- "help_message": "Popište svůj problém",
- "send": "Odeslat",
- "cancel": "Zrušit",
- "help_sent": "Žádost o pomoc odeslána",
- "help_failed": "Nepodařilo se odeslat žádost o pomoc",
- "connected": "Připojeno",
- "disconnected": "Připojování...",
- "save": "Uložit",
- "settings": "Nastavení",
- "test_connection": "Test připojení",
- "test_running": "Testování připojení…",
- "test_ok": "Připojení v pořádku",
- "test_failed": "Problém s připojením",
- "test_gateway": "Vzdálená brána",
- "test_api": "Správcovské API",
- "test_enrollment": "API registrace zařízení",
- "close": "Zavřít",
- "password_too_short": "Heslo musí mít alespoň 6 znaků",
- "status_ready": "Připraveno k připojení (zabezpečené připojení)",
- "enrollment_pending": "Čeká",
- "enrollment_rejected": "Zamítnuto",
- "enrollment_error": "Registrace se nezdařila",
- "consent_title": "Informace o expertovi",
- "consent_prompt": "Povolit připojení od",
- "consent_ack": "Přečetl(a) jsem informace o expertovi.",
- "consent_display_name": "Zobrazované jméno",
- "consent_session": "Relace",
- "session_active": "Aktivní relace",
- "session_with": "Relace s",
- "session_disconnect": "Skrýt panel relace",
- "disconnect": "Odpojit",
- "chat_with_support": "Chat s podporou",
- "settings_language": "Jazyk",
- "quit": "Ukončit",
- "consent_accept": "Pokračovat",
- "consent_deny": "Zrušit relaci",
- "chat_title": "Konverzace",
- "chat_send": "Odeslat",
- "chat_placeholder": "Napište zprávu...",
- "chat_empty": "Zatím žádné zprávy",
- "totp_title": "Dvoufaktorové ověření",
- "totp_enabled": "2FA je na tomto zařízení povoleno",
- "totp_disabled": "2FA není povoleno",
- "totp_setup": "Nastavit 2FA",
- "totp_disable": "Zakázat 2FA",
- "totp_manual_key": "Ruční klíč",
- "totp_step2": "Naskenujte URI v autentizační aplikaci a zadejte kód",
- "totp_enter_code": "Zadejte ověřovací kód",
- "totp_verify_enable": "Ověřit a povolit",
- "totp_invalid_code": "Neplatný ověřovací kód",
- "totp_enabled_success": "Dvoufaktorové ověření povoleno",
- "totp_disabled_success": "Dvoufaktorové ověření zakázáno",
- "totp_setup_failed": "Nepodařilo se spustit nastavení 2FA",
- "totp_required": "Vyžadováno dvoufaktorové ověření",
- "totp_wrong_code": "Neplatný kód 2FA"
-}
diff --git a/betterdesk-support-agent/locales/da.json b/betterdesk-support-agent/locales/da.json
deleted file mode 100644
index 2a2c133d..00000000
--- a/betterdesk-support-agent/locales/da.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Støtte",
- "your_id": "Dit ID",
- "access_password": "Adgangskode",
- "show": "Vis",
- "hide": "Skjul",
- "copy": "Kopier",
- "copied": "Kopieret til udklipsholder",
- "regenerate": "Generer ny",
- "password_regenerated": "Ny adgangskode genereret",
- "set_custom": "Angiv brugerdefineret adgangskode",
- "custom_password": "Brugerdefineret adgangskode",
- "access_mode": "Adgangstilstand",
- "mode_supervised": "Spørg hver gang",
- "mode_unattended": "Uovervåget adgang",
- "mode_disabled": "Deaktiveret",
- "receive_support": "Få support",
- "receive_support_hint": "Anmod om hjælp eller chat med support. Du kan også dele dit ID og din adgangskode nedenfor.",
- "or_share_id": "Eller del dit ID",
- "share_id_password": "Del ID og adgangskode",
- "share_id_hint": "Del disse oplysninger, så support kan oprette forbindelse til din enhed.",
- "ongoing_session": "Igangværende session",
- "close_session": "Luk session",
- "request_help": "Anmod om hjælp",
- "help_message": "Beskriv dit problem",
- "send": "Send besked",
- "cancel": "Annuller",
- "help_sent": "Hjælpeanmodning sendt",
- "help_failed": "Kunne ikke sende hjælpeanmodning",
- "connected": "Forbundet",
- "disconnected": "Opretter forbindelse...",
- "save": "Gem",
- "settings": "Indstillinger",
- "test_connection": "Test forbindelse",
- "test_running": "Tester forbindelse…",
- "test_ok": "Forbindelse OK",
- "test_failed": "Forbindelsesproblem",
- "test_gateway": "Remote-gateway",
- "test_api": "Administrations-API",
- "test_enrollment": "Enhedsregistrerings-API",
- "close": "Luk",
- "password_too_short": "Adgangskode skal være mindst 6 tegn",
- "status_ready": "Klar til at oprette forbindelse (sikker forbindelse)",
- "enrollment_pending": "Afventer",
- "enrollment_rejected": "Afvist",
- "enrollment_error": "Registrering mislykkedes",
- "consent_title": "Ekspertinformation",
- "consent_prompt": "Tillad forbindelse fra",
- "consent_ack": "Jeg har læst ekspertinformationen.",
- "consent_display_name": "Visningsnavn",
- "consent_session": "Session",
- "session_active": "Aktiv session",
- "session_with": "Session med",
- "session_disconnect": "Skjul sessionslinje",
- "disconnect": "Afbryd",
- "chat_with_support": "Chat med support",
- "settings_language": "Sprog",
- "quit": "Afslut",
- "consent_accept": "Fortsæt",
- "consent_deny": "Annuller session",
- "chat_title": "Samtale",
- "chat_send": "Send besked",
- "chat_placeholder": "Skriv en besked...",
- "chat_empty": "Ingen beskeder endnu",
- "totp_title": "To-faktor-godkendelse",
- "totp_enabled": "2FA er aktiveret på denne enhed",
- "totp_disabled": "2FA er ikke aktiveret",
- "totp_setup": "Konfigurer 2FA",
- "totp_disable": "Deaktiver 2FA",
- "totp_manual_key": "Manuel nøgle",
- "totp_step2": "Scan URI i din godkendelsesapp, og indtast koden",
- "totp_enter_code": "Indtast bekræftelseskode",
- "totp_verify_enable": "Bekræft og aktiver",
- "totp_invalid_code": "Ugyldig bekræftelseskode",
- "totp_enabled_success": "To-faktor-godkendelse aktiveret",
- "totp_disabled_success": "To-faktor-godkendelse deaktiveret",
- "totp_setup_failed": "Kunne ikke starte 2FA-opsætning",
- "totp_required": "To-faktor-godkendelse påkrævet",
- "totp_wrong_code": "Ugyldig 2FA-kode"
-}
diff --git a/betterdesk-support-agent/locales/de.json b/betterdesk-support-agent/locales/de.json
deleted file mode 100644
index eaea56a3..00000000
--- a/betterdesk-support-agent/locales/de.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Unterstützung",
- "your_id": "Ihre ID",
- "access_password": "Passwort",
- "show": "Anzeigen",
- "hide": "Ausblenden",
- "copy": "Kopieren",
- "copied": "In die Zwischenablage kopiert",
- "regenerate": "Neu generieren",
- "password_regenerated": "Neues Passwort erzeugt",
- "set_custom": "Eigenes Passwort festlegen",
- "custom_password": "Eigenes Passwort",
- "access_mode": "Zugriffsmodus",
- "mode_supervised": "Jedes Mal fragen",
- "mode_unattended": "Unbeaufsichtigter Zugriff",
- "mode_disabled": "Deaktiviert",
- "receive_support": "Support erhalten",
- "receive_support_hint": "Hilfe anfordern oder mit dem Support chatten. Sie können auch Ihre ID und Ihr Passwort unten teilen.",
- "or_share_id": "Oder ID teilen",
- "share_id_password": "ID und Passwort teilen",
- "share_id_hint": "Teilen Sie diese Zugangsdaten, damit der Support sich verbinden kann.",
- "ongoing_session": "Laufende Sitzung",
- "close_session": "Sitzung beenden",
- "request_help": "Hilfe anfordern",
- "help_message": "Beschreiben Sie Ihr Problem",
- "send": "Senden",
- "cancel": "Abbrechen",
- "help_sent": "Hilfeanfrage gesendet",
- "help_failed": "Hilfeanfrage konnte nicht gesendet werden",
- "connected": "Verbunden",
- "disconnected": "Verbinde...",
- "save": "Speichern",
- "settings": "Einstellungen",
- "test_connection": "Verbindung testen",
- "test_running": "Verbindung wird getestet…",
- "test_ok": "Verbindung OK",
- "test_failed": "Verbindungsproblem",
- "test_gateway": "Remote-Gateway",
- "test_api": "Verwaltungs-API",
- "test_enrollment": "Geräteregistrierungs-API",
- "close": "Schließen",
- "password_too_short": "Passwort muss mindestens 6 Zeichen haben",
- "status_ready": "Bereit zur Verbindung (sichere Verbindung)",
- "enrollment_pending": "Ausstehend",
- "enrollment_rejected": "Abgelehnt",
- "enrollment_error": "Registrierung fehlgeschlagen",
- "consent_title": "Experteninformationen",
- "consent_prompt": "Verbindung zulassen von",
- "consent_ack": "Ich habe die Experteninformationen gelesen.",
- "consent_display_name": "Anzeigename",
- "consent_session": "Sitzung",
- "session_active": "Aktive Sitzung",
- "session_with": "Sitzung mit",
- "session_disconnect": "Sitzungsleiste ausblenden",
- "disconnect": "Trennen",
- "chat_with_support": "Mit Support chatten",
- "settings_language": "Sprache",
- "quit": "Beenden",
- "consent_accept": "Weiter",
- "consent_deny": "Sitzung abbrechen",
- "chat_title": "Dashboard",
- "chat_send": "Senden",
- "chat_placeholder": "Nachricht eingeben...",
- "chat_empty": "Noch keine Nachrichten",
- "totp_title": "Zwei-Faktor-Authentifizierung",
- "totp_enabled": "2FA ist für dieses Gerät aktiviert",
- "totp_disabled": "2FA ist nicht aktiviert",
- "totp_setup": "2FA einrichten",
- "totp_disable": "2FA deaktivieren",
- "totp_manual_key": "Manueller Schlüssel",
- "totp_step2": "Scannen Sie die URI in Ihrer Authenticator-App und geben Sie den Code ein",
- "totp_enter_code": "Bestätigungscode",
- "totp_verify_enable": "Verifizieren und aktivieren",
- "totp_invalid_code": "Ungültiger Verifizierungscode",
- "totp_enabled_success": "Zwei-Faktor-Authentifizierung aktiviert",
- "totp_disabled_success": "Zwei-Faktor-Authentifizierung deaktiviert",
- "totp_setup_failed": "2FA-Einrichtung konnte nicht gestartet werden",
- "totp_required": "Zwei-Faktor-Authentifizierung erforderlich",
- "totp_wrong_code": "Ungültiger 2FA-Code"
-}
diff --git a/betterdesk-support-agent/locales/en.json b/betterdesk-support-agent/locales/en.json
deleted file mode 100644
index 1b67f72a..00000000
--- a/betterdesk-support-agent/locales/en.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Support",
- "your_id": "Your ID",
- "access_password": "Password",
- "show": "Show",
- "hide": "Hide",
- "copy": "Copy",
- "copied": "Copied to clipboard",
- "regenerate": "Generate new",
- "password_regenerated": "New password generated",
- "set_custom": "Set custom password",
- "custom_password": "Custom password",
- "access_mode": "Access mode",
- "mode_supervised": "Ask each time",
- "mode_unattended": "Unattended access",
- "mode_disabled": "Disabled",
- "receive_support": "Get support",
- "receive_support_hint": "Request help or chat with support. You can also share your ID and password below.",
- "or_share_id": "Or share your ID",
- "share_id_password": "Share ID and password",
- "share_id_hint": "Share these credentials so a supporter can connect to your device.",
- "ongoing_session": "Ongoing session",
- "close_session": "Close session",
- "request_help": "Request help",
- "help_message": "Describe your problem",
- "send": "Send",
- "cancel": "Cancel",
- "help_sent": "Help request sent",
- "help_failed": "Could not send help request",
- "connected": "Connected",
- "disconnected": "Connecting...",
- "save": "Save",
- "settings": "Settings",
- "test_connection": "Test connection",
- "test_running": "Testing connection…",
- "test_ok": "Connection OK",
- "test_failed": "Connection problem",
- "test_gateway": "Remote gateway",
- "test_api": "Management API",
- "test_enrollment": "Device registration API",
- "close": "Close",
- "password_too_short": "Password must be at least 6 characters",
- "status_ready": "Ready to connect (secure connection)",
- "enrollment_pending": "Pending",
- "enrollment_rejected": "Rejected",
- "enrollment_error": "Registration failed",
- "consent_title": "Expert information",
- "consent_prompt": "Allow connection from",
- "consent_ack": "I have read the expert information.",
- "consent_display_name": "Display name",
- "consent_session": "Session",
- "session_active": "Active session",
- "session_with": "Session with",
- "session_disconnect": "Hide session bar",
- "disconnect": "Disconnect",
- "chat_with_support": "Chat with support",
- "settings_language": "Language",
- "quit": "Quit",
- "consent_accept": "Continue",
- "consent_deny": "Cancel session",
- "chat_title": "Chat",
- "chat_send": "Send",
- "chat_placeholder": "Type a message...",
- "chat_empty": "No messages yet",
- "totp_title": "Two-factor authentication",
- "totp_enabled": "2FA is enabled for this device",
- "totp_disabled": "2FA is not enabled",
- "totp_setup": "Set up 2FA",
- "totp_disable": "Disable 2FA",
- "totp_manual_key": "Manual key",
- "totp_step2": "Scan the URI in your authenticator app, then enter the code",
- "totp_enter_code": "Enter verification code",
- "totp_verify_enable": "Verify and enable",
- "totp_invalid_code": "Invalid verification code",
- "totp_enabled_success": "Two-factor authentication enabled",
- "totp_disabled_success": "Two-factor authentication disabled",
- "totp_setup_failed": "Could not start 2FA setup",
- "totp_required": "Two-factor authentication required",
- "totp_wrong_code": "Invalid two-factor code"
-}
diff --git a/betterdesk-support-agent/locales/es.json b/betterdesk-support-agent/locales/es.json
deleted file mode 100644
index 8f4b5ce2..00000000
--- a/betterdesk-support-agent/locales/es.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Soporte",
- "your_id": "Tu ID",
- "access_password": "Contraseña",
- "show": "Mostrar",
- "hide": "Ocultar",
- "copy": "Copiar",
- "copied": "Copied!",
- "regenerate": "Generar nuevo",
- "password_regenerated": "Nueva contraseña generada",
- "set_custom": "Establecer contraseña personalizada",
- "custom_password": "Contraseña personalizada",
- "access_mode": "Modo de acceso",
- "mode_supervised": "Preguntar cada vez",
- "mode_unattended": "Acceso desatendido",
- "mode_disabled": "Desactivado",
- "receive_support": "Recibir asistencia",
- "receive_support_hint": "Solicite ayuda o chatee con soporte. También puede compartir su ID y contraseña abajo.",
- "or_share_id": "O comparta su ID",
- "share_id_password": "Compartir ID y contraseña",
- "share_id_hint": "Comparta estas credenciales para que el soporte pueda conectarse a su dispositivo.",
- "ongoing_session": "Sesión en curso",
- "close_session": "Cerrar sesión",
- "request_help": "Solicitar ayuda",
- "help_message": "Describa su problema",
- "send": "Enviar",
- "cancel": "Cancelar",
- "help_sent": "Solicitud de ayuda enviada",
- "help_failed": "No se pudo enviar la solicitud de ayuda",
- "connected": "Conectado",
- "disconnected": "Conectando...",
- "save": "Guardar",
- "settings": "Configuración",
- "test_connection": "Probar conexión",
- "test_running": "Cargando...",
- "test_ok": "Éxito",
- "test_failed": "Error",
- "test_gateway": "Puerta de enlace remota",
- "test_api": "API de gestión",
- "test_enrollment": "API de registro del dispositivo",
- "close": "Cerrar",
- "password_too_short": "La contraseña debe tener al menos 6 caracteres",
- "status_ready": "Listo para conectar (conexión segura)",
- "enrollment_pending": "Pendiente",
- "enrollment_rejected": "Rechazado",
- "enrollment_error": "Error en el registro",
- "consent_title": "Información del experto",
- "consent_prompt": "Permitir conexión de",
- "consent_ack": "He leído la información del experto.",
- "consent_display_name": "Nombre para mostrar",
- "consent_session": "Sesión",
- "session_active": "Sesión activa",
- "session_with": "Sesión con",
- "session_disconnect": "Ocultar barra de sesión",
- "disconnect": "Desconectar",
- "chat_with_support": "Chatear con soporte",
- "settings_language": "Idioma",
- "quit": "Salir",
- "consent_accept": "Continuar",
- "consent_deny": "Cancelar sesión",
- "chat_title": "Panel",
- "chat_send": "Enviar",
- "chat_placeholder": "Escriba un mensaje...",
- "chat_empty": "Aún no hay mensajes",
- "totp_title": "Autenticación de dos factores",
- "totp_enabled": "2FA activada en este dispositivo",
- "totp_disabled": "2FA no activada",
- "totp_setup": "Configurar 2FA",
- "totp_disable": "Desactivar 2FA",
- "totp_manual_key": "Clave manual",
- "totp_step2": "Escanee la URI en su aplicación de autenticación e introduzca el código",
- "totp_enter_code": "Código de verificación",
- "totp_verify_enable": "Verificar y activar",
- "totp_invalid_code": "Código de verificación no válido",
- "totp_enabled_success": "Autenticación de dos factores activada",
- "totp_disabled_success": "Autenticación de dos factores desactivada",
- "totp_setup_failed": "No se pudo iniciar la configuración 2FA",
- "totp_required": "Se requiere autenticación de dos factores",
- "totp_wrong_code": "Código 2FA no válido"
-}
diff --git a/betterdesk-support-agent/locales/fi.json b/betterdesk-support-agent/locales/fi.json
deleted file mode 100644
index d1a0da3b..00000000
--- a/betterdesk-support-agent/locales/fi.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Tuki",
- "your_id": "Sinun henkilöllisyystodistus",
- "access_password": "Salasana",
- "show": "Näytä",
- "hide": "Piilota",
- "copy": "Kopioi",
- "copied": "Kopioitu leikepöydälle",
- "regenerate": "Luo uusi",
- "password_regenerated": "Uusi salasana luotu",
- "set_custom": "Aseta mukautettu salasana",
- "custom_password": "Mukautettu salasana",
- "access_mode": "Käyttötila",
- "mode_supervised": "Kysy joka kerta",
- "mode_unattended": "Valvomaton käyttö",
- "mode_disabled": "Poissa käytöstä",
- "receive_support": "Saa tukea",
- "receive_support_hint": "Pyydä apua tai keskustele tuen kanssa. Voit myös jakaa ID:si ja salasanasi alla.",
- "or_share_id": "Tai jaa ID-tunnuksesi",
- "share_id_password": "Jaa ID ja salasana",
- "share_id_hint": "Jaa nämä tunnukset, jotta tuki voi yhdistää laitteeseesi.",
- "ongoing_session": "Käynnissä oleva istunto",
- "close_session": "Sulje istunto",
- "request_help": "Pyydä apua",
- "help_message": "Kuvaile ongelmasi",
- "send": "Lähetä",
- "cancel": "Peruuta",
- "help_sent": "Apupyyntö lähetetty",
- "help_failed": "Apupyyntöä ei voitu lähettää",
- "connected": "Yhdistetty",
- "disconnected": "Yhdistetään...",
- "save": "Tallenna",
- "settings": "Asetukset",
- "test_connection": "Testaa yhteys",
- "test_running": "Testataan yhteyttä…",
- "test_ok": "Yhteys kunnossa",
- "test_failed": "Yhteysongelma",
- "test_gateway": "Etäyhdyskäytävä",
- "test_api": "Hallinta-API",
- "test_enrollment": "Laitteen rekisteröinti-API",
- "close": "Sulje",
- "password_too_short": "Salasanassa oltava vähintään 6 merkkiä",
- "status_ready": "Valmis yhdistämään (suojattu yhteys)",
- "enrollment_pending": "Odottaa",
- "enrollment_rejected": "Hylätty",
- "enrollment_error": "Rekisteröinti epäonnistui",
- "consent_title": "Asiantuntijatiedot",
- "consent_prompt": "Salli yhteys käyttäjältä",
- "consent_ack": "Olen lukenut asiantuntijatiedot.",
- "consent_display_name": "Näyttönimi",
- "consent_session": "Istunto",
- "session_active": "Aktiivinen istunto",
- "session_with": "Istunto:",
- "session_disconnect": "Piilota istuntopalkki",
- "disconnect": "Katkaise yhteys",
- "chat_with_support": "Keskustele tuen kanssa",
- "settings_language": "Kieli",
- "quit": "Lopeta",
- "consent_accept": "Jatka",
- "consent_deny": "Peruuta istunto",
- "chat_title": "Keskustelu",
- "chat_send": "Lähetä",
- "chat_placeholder": "Kirjoita viesti...",
- "chat_empty": "Ei viestejä vielä",
- "totp_title": "Kaksivaiheinen tunnistus",
- "totp_enabled": "2FA on käytössä tällä laitteella",
- "totp_disabled": "2FA ei ole käytössä",
- "totp_setup": "Määritä 2FA",
- "totp_disable": "Poista 2FA käytöstä",
- "totp_manual_key": "Manuaalinen avain",
- "totp_step2": "Skannaa URI tunnistussovelluksessa ja syötä koodi",
- "totp_enter_code": "Syötä vahvistuskoodi",
- "totp_verify_enable": "Vahvista ja ota käyttöön",
- "totp_invalid_code": "Virheellinen vahvistuskoodi",
- "totp_enabled_success": "Kaksivaiheinen tunnistus otettu käyttöön",
- "totp_disabled_success": "Kaksivaiheinen tunnistus poistettu käytöstä",
- "totp_setup_failed": "2FA-määritystä ei voitu aloittaa",
- "totp_required": "Kaksivaiheinen tunnistus vaaditaan",
- "totp_wrong_code": "Virheellinen 2FA-koodi"
-}
diff --git a/betterdesk-support-agent/locales/fr.json b/betterdesk-support-agent/locales/fr.json
deleted file mode 100644
index 3f188bd3..00000000
--- a/betterdesk-support-agent/locales/fr.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Assistance",
- "your_id": "Votre ID",
- "access_password": "Mot de passe",
- "show": "Afficher",
- "hide": "Masquer",
- "copy": "Copier",
- "copied": "Copié dans le presse-papiers",
- "regenerate": "Générer un nouveau",
- "password_regenerated": "Nouveau mot de passe généré",
- "set_custom": "Définir un mot de passe personnalisé",
- "custom_password": "Mot de passe personnalisé",
- "access_mode": "Mode d'accès",
- "mode_supervised": "Demander à chaque fois",
- "mode_unattended": "Accès sans surveillance",
- "mode_disabled": "Désactivé",
- "receive_support": "Recevoir de l’aide",
- "receive_support_hint": "Demandez de l’aide ou discutez avec le support. Vous pouvez aussi partager votre ID et mot de passe ci-dessous.",
- "or_share_id": "Ou partagez votre ID",
- "share_id_password": "Partager l’ID et le mot de passe",
- "share_id_hint": "Partagez ces identifiants pour qu’un assistant puisse se connecter à votre appareil.",
- "ongoing_session": "Session en cours",
- "close_session": "Fermer la session",
- "request_help": "Demander de l'aide",
- "help_message": "Décrivez votre problème",
- "send": "Envoyer",
- "cancel": "Annuler",
- "help_sent": "Demande d'aide envoyée",
- "help_failed": "Impossible d'envoyer la demande d'aide",
- "connected": "Connecté",
- "disconnected": "Connexion en cours...",
- "save": "Enregistrer",
- "settings": "Paramètres",
- "test_connection": "Tester la connexion",
- "test_running": "Chargement...",
- "test_ok": "Succès",
- "test_failed": "Erreur",
- "test_gateway": "Passerelle distante",
- "test_api": "API de gestion",
- "test_enrollment": "API d'enregistrement de l'appareil",
- "close": "Fermer",
- "password_too_short": "Le mot de passe doit contenir au moins 6 caractères",
- "status_ready": "Prêt à se connecter (connexion sécurisée)",
- "enrollment_pending": "En attente",
- "enrollment_rejected": "Rejeté",
- "enrollment_error": "Échec de l'enregistrement",
- "consent_title": "Informations sur l’expert",
- "consent_prompt": "Autoriser la connexion de",
- "consent_ack": "J’ai lu les informations sur l’expert.",
- "consent_display_name": "Nom affiché",
- "consent_session": "Session",
- "session_active": "Session active",
- "session_with": "Session avec",
- "session_disconnect": "Masquer la barre de session",
- "disconnect": "Déconnecter",
- "chat_with_support": "Discuter avec le support",
- "settings_language": "Langue",
- "quit": "Quitter",
- "consent_accept": "Continuer",
- "consent_deny": "Annuler la session",
- "chat_title": "Tableau de bord",
- "chat_send": "Envoyer",
- "chat_placeholder": "Saisissez un message...",
- "chat_empty": "Aucun message pour le moment",
- "totp_title": "Authentification à deux facteurs",
- "totp_enabled": "2FA activée sur cet appareil",
- "totp_disabled": "2FA non activée",
- "totp_setup": "Configurer la 2FA",
- "totp_disable": "Désactiver la 2FA",
- "totp_manual_key": "Clé manuelle",
- "totp_step2": "Scannez l'URI dans votre application d'authentification, puis saisissez le code",
- "totp_enter_code": "Code de vérification",
- "totp_verify_enable": "Vérifier et activer",
- "totp_invalid_code": "Code de vérification invalide",
- "totp_enabled_success": "Authentification à deux facteurs activée",
- "totp_disabled_success": "Authentification à deux facteurs désactivée",
- "totp_setup_failed": "Impossible de démarrer la configuration 2FA",
- "totp_required": "Authentification à deux facteurs requise",
- "totp_wrong_code": "Code 2FA invalide"
-}
diff --git a/betterdesk-support-agent/locales/hi.json b/betterdesk-support-agent/locales/hi.json
deleted file mode 100644
index 5b18e783..00000000
--- a/betterdesk-support-agent/locales/hi.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "सहायता",
- "your_id": "आपका आईडी",
- "access_password": "पासवर्ड",
- "show": "दिखाएँ",
- "hide": "छिपाएँ",
- "copy": "कॉपी करें",
- "copied": "क्लिपबोर्ड पर कॉपी किया गया",
- "regenerate": "नया बनाएँ",
- "password_regenerated": "नया पासवर्ड बनाया गया",
- "set_custom": "कस्टम पासवर्ड सेट करें",
- "custom_password": "कस्टम पासवर्ड",
- "access_mode": "एक्सेस मोड",
- "mode_supervised": "हर बार पूछें",
- "mode_unattended": "बिना निगरानी एक्सेस",
- "mode_disabled": "अक्षम",
- "receive_support": "सहायता प्राप्त करें",
- "receive_support_hint": "मदद का अनुरोध करें या सपोर्ट से चैट करें। आप नीचे अपना ID और पासवर्ड भी साझा कर सकते हैं।",
- "or_share_id": "या अपना ID साझा करें",
- "share_id_password": "ID और पासवर्ड साझा करें",
- "share_id_hint": "ये क्रेडेंशियल साझा करें ताकि सपोर्ट आपके डिवाइस से जुड़ सके।",
- "ongoing_session": "चल रहा सत्र",
- "close_session": "सत्र बंद करें",
- "request_help": "सहायता का अनुरोध",
- "help_message": "अपनी समस्या बताएँ",
- "send": "भेजना",
- "cancel": "रद्द करना",
- "help_sent": "सहायता अनुरोध भेजा गया",
- "help_failed": "सहायता अनुरोध नहीं भेजा जा सका",
- "connected": "जुड़ा हुआ",
- "disconnected": "कनेक्ट हो रहा है...",
- "save": "बचाना",
- "settings": "सेटिंग्स",
- "test_connection": "कनेक्शन परीक्षण",
- "test_running": "कनेक्शन परीक्षण हो रहा है…",
- "test_ok": "कनेक्शन ठीक",
- "test_failed": "कनेक्शन समस्या",
- "test_gateway": "रिमोट गेटवे",
- "test_api": "प्रबंधन API",
- "test_enrollment": "डिवाइस पंजीकरण API",
- "close": "बंद करना",
- "password_too_short": "पासवर्ड कम से कम 6 अक्षर का होना चाहिए",
- "status_ready": "कनेक्ट करने के लिए तैयार (सुरक्षित कनेक्शन)",
- "enrollment_pending": "लंबित",
- "enrollment_rejected": "अस्वीकृत",
- "enrollment_error": "पंजीकरण विफल",
- "consent_title": "विशेषज्ञ जानकारी",
- "consent_prompt": "इससे कनेक्शन की अनुमति दें",
- "consent_ack": "मैंने विशेषज्ञ जानकारी पढ़ ली है।",
- "consent_display_name": "प्रदर्शित नाम",
- "consent_session": "सत्र",
- "session_active": "सक्रिय सत्र",
- "session_with": "सत्र:",
- "session_disconnect": "सत्र पट्टी छिपाएँ",
- "disconnect": "डिस्कनेक्ट",
- "chat_with_support": "समर्थन के साथ चैट करें",
- "settings_language": "भाषा",
- "quit": "बंद करें",
- "consent_accept": "जारी रखें",
- "consent_deny": "सत्र रद्द करें",
- "chat_title": "चैट",
- "chat_send": "भेजना",
- "chat_placeholder": "एक संदेश टाइप करें...",
- "chat_empty": "अभी तक कोई संदेश नहीं",
- "totp_title": "दो-कारक प्रमाणीकरण",
- "totp_enabled": "इस डिवाइस पर 2FA सक्षम है",
- "totp_disabled": "2FA सक्षम नहीं है",
- "totp_setup": "2FA सेट करें",
- "totp_disable": "2FA अक्षम करें",
- "totp_manual_key": "मैन्युअल कुंजी",
- "totp_step2": "प्रमाणक ऐप में URI स्कैन करें, फिर कोड दर्ज करें",
- "totp_enter_code": "सत्यापन कोड दर्ज करें",
- "totp_verify_enable": "सत्यापित करें और सक्षम करें",
- "totp_invalid_code": "अमान्य सत्यापन कोड",
- "totp_enabled_success": "दो-कारक प्रमाणीकरण सक्षम",
- "totp_disabled_success": "दो-कारक प्रमाणीकरण अक्षम",
- "totp_setup_failed": "2FA सेटअप शुरू नहीं हो सका",
- "totp_required": "दो-कारक प्रमाणीकरण आवश्यक",
- "totp_wrong_code": "अमान्य 2FA कोड"
-}
diff --git a/betterdesk-support-agent/locales/hu.json b/betterdesk-support-agent/locales/hu.json
deleted file mode 100644
index 51f82082..00000000
--- a/betterdesk-support-agent/locales/hu.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Támogatás",
- "your_id": "Az Ön személyi igazolványa",
- "access_password": "Jelszó",
- "show": "Megjelenítés",
- "hide": "Elrejtés",
- "copy": "Másolás",
- "copied": "Vágólapra másolva",
- "regenerate": "Új generálása",
- "password_regenerated": "Új jelszó generálva",
- "set_custom": "Egyéni jelszó beállítása",
- "custom_password": "Egyéni jelszó",
- "access_mode": "Hozzáférési mód",
- "mode_supervised": "Minden alkalommal kérdezzen",
- "mode_unattended": "Felügyelet nélküli hozzáférés",
- "mode_disabled": "Letiltva",
- "receive_support": "Támogatás kérése",
- "receive_support_hint": "Kérjen segítséget vagy csevegjen a támogatással. Az ID-t és a jelszót lent is megoszthatja.",
- "or_share_id": "Vagy ossza meg az ID-t",
- "share_id_password": "ID és jelszó megosztása",
- "share_id_hint": "Ossza meg ezeket az adatokat, hogy a támogatás csatlakozhasson az eszközéhez.",
- "ongoing_session": "Folyamatban lévő munkamenet",
- "close_session": "Munkamenet bezárása",
- "request_help": "Segítség kérése",
- "help_message": "Írja le a problémát",
- "send": "Küldés",
- "cancel": "Mégse",
- "help_sent": "Segítségkérés elküldve",
- "help_failed": "Nem sikerült elküldeni a segítségkérést",
- "connected": "Csatlakoztatva",
- "disconnected": "Csatlakozás...",
- "save": "Mentés",
- "settings": "Beállítások elemre",
- "test_connection": "Kapcsolat tesztelése",
- "test_running": "Kapcsolat tesztelése…",
- "test_ok": "Kapcsolat rendben",
- "test_failed": "Kapcsolati probléma",
- "test_gateway": "Távoli átjáró",
- "test_api": "Kezelő API",
- "test_enrollment": "Eszközregisztrációs API",
- "close": "Bezárás",
- "password_too_short": "A jelszónak legalább 6 karakterből kell állnia",
- "status_ready": "Kapcsolódásra kész (biztonságos kapcsolat)",
- "enrollment_pending": "Függőben",
- "enrollment_rejected": "Elutasítva",
- "enrollment_error": "Regisztráció sikertelen",
- "consent_title": "Szakértői információk",
- "consent_prompt": "Kapcsolat engedélyezése:",
- "consent_ack": "Elolvastam a szakértői információkat.",
- "consent_display_name": "Megjelenített név",
- "consent_session": "Munkamenet",
- "session_active": "Aktív munkamenet",
- "session_with": "Munkamenet:",
- "session_disconnect": "Munkamenetsáv elrejtése",
- "disconnect": "Szétkapcsolás",
- "chat_with_support": "Csevegés támogatással",
- "settings_language": "Nyelv",
- "quit": "Kilépés",
- "consent_accept": "Folytatás",
- "consent_deny": "Munkamenet megszakítása",
- "chat_title": "Csevegés",
- "chat_send": "Küldés",
- "chat_placeholder": "Írjon be egy üzenetet...",
- "chat_empty": "Még nincsenek üzenetek",
- "totp_title": "Kétfaktoros hitelesítés",
- "totp_enabled": "A 2FA engedélyezve van ezen az eszközön",
- "totp_disabled": "A 2FA nincs engedélyezve",
- "totp_setup": "2FA beállítása",
- "totp_disable": "2FA letiltása",
- "totp_manual_key": "Manuális kulcs",
- "totp_step2": "Olvassa be az URI-t a hitelesítő alkalmazásban, majd adja meg a kódot",
- "totp_enter_code": "Adja meg az ellenőrző kódot",
- "totp_verify_enable": "Ellenőrzés és engedélyezés",
- "totp_invalid_code": "Érvénytelen ellenőrző kód",
- "totp_enabled_success": "Kétfaktoros hitelesítés engedélyezve",
- "totp_disabled_success": "Kétfaktoros hitelesítés letiltva",
- "totp_setup_failed": "A 2FA beállítása nem indítható el",
- "totp_required": "Kétfaktoros hitelesítés szükséges",
- "totp_wrong_code": "Érvénytelen 2FA kód"
-}
diff --git a/betterdesk-support-agent/locales/id.json b/betterdesk-support-agent/locales/id.json
deleted file mode 100644
index d61793c4..00000000
--- a/betterdesk-support-agent/locales/id.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Dukungan",
- "your_id": "tanda pengenal Anda",
- "access_password": "Kata sandi",
- "show": "Tampilkan",
- "hide": "Sembunyikan",
- "copy": "Salin",
- "copied": "Disalin ke papan klip",
- "regenerate": "Buat baru",
- "password_regenerated": "Kata sandi baru dibuat",
- "set_custom": "Atur kata sandi khusus",
- "custom_password": "Kata sandi khusus",
- "access_mode": "Mode akses",
- "mode_supervised": "Tanya setiap kali",
- "mode_unattended": "Akses tanpa pengawasan",
- "mode_disabled": "Nonaktif",
- "receive_support": "Dapatkan dukungan",
- "receive_support_hint": "Minta bantuan atau mengobrol dengan dukungan. Anda juga dapat membagikan ID dan kata sandi di bawah.",
- "or_share_id": "Atau bagikan ID Anda",
- "share_id_password": "Bagikan ID dan kata sandi",
- "share_id_hint": "Bagikan kredensial ini agar dukungan dapat terhubung ke perangkat Anda.",
- "ongoing_session": "Sesi berlangsung",
- "close_session": "Tutup sesi",
- "request_help": "Minta bantuan",
- "help_message": "Jelaskan masalah Anda",
- "send": "Kirim",
- "cancel": "Batalkan",
- "help_sent": "Permintaan bantuan terkirim",
- "help_failed": "Tidak dapat mengirim permintaan bantuan",
- "connected": "Terhubung",
- "disconnected": "Menghubungkan...",
- "save": "Simpan",
- "settings": "Pengaturan",
- "test_connection": "Uji koneksi",
- "test_running": "Menguji koneksi…",
- "test_ok": "Koneksi OK",
- "test_failed": "Masalah koneksi",
- "test_gateway": "Gateway jarak jauh",
- "test_api": "API manajemen",
- "test_enrollment": "API pendaftaran perangkat",
- "close": "Tutup",
- "password_too_short": "Kata sandi minimal 6 karakter",
- "status_ready": "Siap terhubung (koneksi aman)",
- "enrollment_pending": "Menunggu",
- "enrollment_rejected": "Ditolak",
- "enrollment_error": "Pendaftaran gagal",
- "consent_title": "Informasi ahli",
- "consent_prompt": "Izinkan koneksi dari",
- "consent_ack": "Saya telah membaca informasi ahli.",
- "consent_display_name": "Nama tampilan",
- "consent_session": "Sesi",
- "session_active": "Sesi aktif",
- "session_with": "Sesi dengan",
- "session_disconnect": "Sembunyikan bilah sesi",
- "disconnect": "Putuskan",
- "chat_with_support": "Mengobrol dengan dukungan",
- "settings_language": "Bahasa",
- "quit": "Keluar",
- "consent_accept": "Lanjutkan",
- "consent_deny": "Batalkan sesi",
- "chat_title": "Obrolan",
- "chat_send": "Kirim",
- "chat_placeholder": "Ketik pesan...",
- "chat_empty": "Belum ada pesan",
- "totp_title": "Autentikasi dua faktor",
- "totp_enabled": "2FA diaktifkan di perangkat ini",
- "totp_disabled": "2FA tidak diaktifkan",
- "totp_setup": "Atur 2FA",
- "totp_disable": "Nonaktifkan 2FA",
- "totp_manual_key": "Kunci manual",
- "totp_step2": "Pindai URI di aplikasi autentikator, lalu masukkan kode",
- "totp_enter_code": "Masukkan kode verifikasi",
- "totp_verify_enable": "Verifikasi dan aktifkan",
- "totp_invalid_code": "Kode verifikasi tidak valid",
- "totp_enabled_success": "Autentikasi dua faktor diaktifkan",
- "totp_disabled_success": "Autentikasi dua faktor dinonaktifkan",
- "totp_setup_failed": "Tidak dapat memulai pengaturan 2FA",
- "totp_required": "Autentikasi dua faktor diperlukan",
- "totp_wrong_code": "Kode 2FA tidak valid"
-}
diff --git a/betterdesk-support-agent/locales/it.json b/betterdesk-support-agent/locales/it.json
deleted file mode 100644
index 88952dbb..00000000
--- a/betterdesk-support-agent/locales/it.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Supporto",
- "your_id": "Il tuo ID",
- "access_password": "Parola d’accesso",
- "show": "Mostra",
- "hide": "Nascondi",
- "copy": "Copia",
- "copied": "Copiato negli appunti",
- "regenerate": "Genera nuovo",
- "password_regenerated": "Nuova password generata",
- "set_custom": "Imposta password personalizzata",
- "custom_password": "Password personalizzata",
- "access_mode": "Modalità di accesso",
- "mode_supervised": "Chiedi ogni volta",
- "mode_unattended": "Accesso automatico",
- "mode_disabled": "Disabilitato",
- "receive_support": "Ricevi assistenza",
- "receive_support_hint": "Richiedi aiuto o chatta con il supporto. Puoi anche condividere ID e password qui sotto.",
- "or_share_id": "Oppure condividi il tuo ID",
- "share_id_password": "Condividi ID e password",
- "share_id_hint": "Condividi queste credenziali così il supporto può collegarsi al tuo dispositivo.",
- "ongoing_session": "Sessione in corso",
- "close_session": "Chiudi sessione",
- "request_help": "Richiedi aiuto",
- "help_message": "Descrivi il tuo problema",
- "send": "Invia",
- "cancel": "Annulla",
- "help_sent": "Richiesta di aiuto inviata",
- "help_failed": "Impossibile inviare la richiesta di aiuto",
- "connected": "Connesso",
- "disconnected": "Connessione...",
- "save": "Salva",
- "settings": "Impostazioni",
- "test_connection": "Testa connessione",
- "test_running": "Caricamento...",
- "test_ok": "Successo",
- "test_failed": "Errore",
- "test_gateway": "Gateway remoto",
- "test_api": "API di gestione",
- "test_enrollment": "API di registrazione dispositivo",
- "close": "Chiudi",
- "password_too_short": "La password deve contenere almeno 6 caratteri",
- "status_ready": "Pronto per la connessione (connessione sicura)",
- "enrollment_pending": "In attesa",
- "enrollment_rejected": "Rifiutato",
- "enrollment_error": "Registrazione non riuscita",
- "consent_title": "Informazioni sull’esperto",
- "consent_prompt": "Consentire connessione da",
- "consent_ack": "Ho letto le informazioni sull’esperto.",
- "consent_display_name": "Nome visualizzato",
- "consent_session": "Sessione",
- "session_active": "Sessione attiva",
- "session_with": "Sessione con",
- "session_disconnect": "Nascondi barra sessione",
- "disconnect": "Disconnetti",
- "chat_with_support": "Chatta con il supporto",
- "settings_language": "Lingua",
- "quit": "Esci",
- "consent_accept": "Continua",
- "consent_deny": "Annulla sessione",
- "chat_title": "Conversazione",
- "chat_send": "Invia",
- "chat_placeholder": "Scrivi un messaggio...",
- "chat_empty": "Nessun messaggio ancora",
- "totp_title": "Autenticazione a due fattori",
- "totp_enabled": "2FA abilitata su questo dispositivo",
- "totp_disabled": "2FA non abilitata",
- "totp_setup": "Configura 2FA",
- "totp_disable": "Disabilita 2FA",
- "totp_manual_key": "Chiave manuale",
- "totp_step2": "Scansiona l'URI nell'app di autenticazione, poi inserisci il codice",
- "totp_enter_code": "Codice di verifica",
- "totp_verify_enable": "Verifica e abilita",
- "totp_invalid_code": "Codice di verifica non valido",
- "totp_enabled_success": "Autenticazione a due fattori abilitata",
- "totp_disabled_success": "Autenticazione a due fattori disabilitata",
- "totp_setup_failed": "Impossibile avviare la configurazione 2FA",
- "totp_required": "Autenticazione a due fattori richiesta",
- "totp_wrong_code": "Codice 2FA non valido"
-}
diff --git a/betterdesk-support-agent/locales/ja.json b/betterdesk-support-agent/locales/ja.json
deleted file mode 100644
index df683ff1..00000000
--- a/betterdesk-support-agent/locales/ja.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "サポート",
- "your_id": "あなたのID",
- "access_password": "パスワード",
- "show": "表示",
- "hide": "非表示",
- "copy": "コピー",
- "copied": "クリップボードにコピーしました",
- "regenerate": "新規生成",
- "password_regenerated": "新しいパスワードを生成しました",
- "set_custom": "カスタムパスワードを設定",
- "custom_password": "カスタムパスワード",
- "access_mode": "アクセスモード",
- "mode_supervised": "毎回確認",
- "mode_unattended": "無人アクセス",
- "mode_disabled": "無効",
- "receive_support": "サポートを受ける",
- "receive_support_hint": "ヘルプを依頼するかサポートとチャットできます。下のIDとパスワードを共有することもできます。",
- "or_share_id": "またはIDを共有",
- "share_id_password": "IDとパスワードを共有",
- "share_id_hint": "サポートがデバイスに接続できるよう、これらの認証情報を共有してください。",
- "ongoing_session": "進行中のセッション",
- "close_session": "セッションを閉じる",
- "request_help": "ヘルプを依頼",
- "help_message": "問題を説明してください",
- "send": "送信",
- "cancel": "キャンセル",
- "help_sent": "ヘルプリクエストを送信しました",
- "help_failed": "ヘルプリクエストを送信できませんでした",
- "connected": "接続済み",
- "disconnected": "接続中...",
- "save": "保存",
- "settings": "設定",
- "test_connection": "接続テスト",
- "test_running": "接続をテスト中…",
- "test_ok": "接続OK",
- "test_failed": "接続の問題",
- "test_gateway": "リモートゲートウェイ",
- "test_api": "管理API",
- "test_enrollment": "デバイス登録API",
- "close": "閉じる",
- "password_too_short": "パスワードは6文字以上である必要があります",
- "status_ready": "接続準備完了(セキュア接続)",
- "enrollment_pending": "保留中",
- "enrollment_rejected": "拒否",
- "enrollment_error": "登録に失敗しました",
- "consent_title": "専門家情報",
- "consent_prompt": "接続を許可:",
- "consent_ack": "専門家情報を確認しました。",
- "consent_display_name": "表示名",
- "consent_session": "セッション",
- "session_active": "アクティブなセッション",
- "session_with": "セッション:",
- "session_disconnect": "セッションバーを非表示",
- "disconnect": "切断",
- "chat_with_support": "サポートとチャットする",
- "settings_language": "言語",
- "quit": "終了",
- "consent_accept": "続行",
- "consent_deny": "セッションをキャンセル",
- "chat_title": "チャット",
- "chat_send": "送信",
- "chat_placeholder": "メッセージを入力...",
- "chat_empty": "まだメッセージがありません",
- "totp_title": "二要素認証",
- "totp_enabled": "このデバイスで2FAが有効です",
- "totp_disabled": "2FAは無効です",
- "totp_setup": "2FAを設定",
- "totp_disable": "2FAを無効化",
- "totp_manual_key": "手動キー",
- "totp_step2": "認証アプリでURIをスキャンし、コードを入力してください",
- "totp_enter_code": "Verification Code",
- "totp_verify_enable": "確認して有効化",
- "totp_invalid_code": "無効な確認コード",
- "totp_enabled_success": "二要素認証を有効にしました",
- "totp_disabled_success": "二要素認証を無効にしました",
- "totp_setup_failed": "2FAの設定を開始できませんでした",
- "totp_required": "二要素認証が必要です",
- "totp_wrong_code": "無効な2FAコード"
-}
diff --git a/betterdesk-support-agent/locales/ko.json b/betterdesk-support-agent/locales/ko.json
deleted file mode 100644
index 87af3a95..00000000
--- a/betterdesk-support-agent/locales/ko.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "지원",
- "your_id": "귀하의 신분증",
- "access_password": "비밀번호",
- "show": "표시",
- "hide": "숨기기",
- "copy": "복사",
- "copied": "클립보드에 복사됨",
- "regenerate": "새로 생성",
- "password_regenerated": "새 비밀번호가 생성되었습니다",
- "set_custom": "사용자 지정 비밀번호 설정",
- "custom_password": "사용자 지정 비밀번호",
- "access_mode": "액세스 모드",
- "mode_supervised": "매번 확인",
- "mode_unattended": "무인 액세스",
- "mode_disabled": "비활성화",
- "receive_support": "지원 받기",
- "receive_support_hint": "도움을 요청하거나 지원과 채팅하세요. 아래에서 ID와 비밀번호를 공유할 수도 있습니다.",
- "or_share_id": "또는 ID 공유",
- "share_id_password": "ID 및 비밀번호 공유",
- "share_id_hint": "지원이 장치에 연결할 수 있도록 이 자격 증명을 공유하세요.",
- "ongoing_session": "진행 중인 세션",
- "close_session": "세션 종료",
- "request_help": "도움 요청",
- "help_message": "문제를 설명하세요",
- "send": "보내기",
- "cancel": "취소",
- "help_sent": "도움 요청이 전송되었습니다",
- "help_failed": "도움 요청을 보낼 수 없습니다",
- "connected": "연결됨",
- "disconnected": "연결 중...",
- "save": "저장",
- "settings": "설정",
- "test_connection": "연결 테스트",
- "test_running": "연결 테스트 중…",
- "test_ok": "연결 정상",
- "test_failed": "연결 문제",
- "test_gateway": "원격 게이트웨이",
- "test_api": "관리 API",
- "test_enrollment": "장치 등록 API",
- "close": "닫기",
- "password_too_short": "비밀번호는 6자 이상이어야 합니다",
- "status_ready": "연결 준비 완료(보안 연결)",
- "enrollment_pending": "대기 중",
- "enrollment_rejected": "거부됨",
- "enrollment_error": "등록 실패",
- "consent_title": "전문가 정보",
- "consent_prompt": "다음에서 연결 허용",
- "consent_ack": "전문가 정보를 확인했습니다.",
- "consent_display_name": "표시 이름",
- "consent_session": "세션",
- "session_active": "활성 세션",
- "session_with": "세션:",
- "session_disconnect": "세션 표시줄 숨기기",
- "disconnect": "연결 해제",
- "chat_with_support": "지원팀과 채팅",
- "settings_language": "언어",
- "quit": "종료",
- "consent_accept": "계속",
- "consent_deny": "세션 취소",
- "chat_title": "채팅",
- "chat_send": "보내기",
- "chat_placeholder": "메시지를 입력하세요...",
- "chat_empty": "아직 메시지가 없습니다",
- "totp_title": "2단계 인증",
- "totp_enabled": "이 장치에서 2FA가 활성화됨",
- "totp_disabled": "2FA가 비활성화됨",
- "totp_setup": "2FA 설정",
- "totp_disable": "2FA 비활성화",
- "totp_manual_key": "수동 키",
- "totp_step2": "인증 앱에서 URI를 스캔한 후 코드를 입력하세요",
- "totp_enter_code": "인증 코드 입력",
- "totp_verify_enable": "확인 후 활성화",
- "totp_invalid_code": "잘못된 인증 코드",
- "totp_enabled_success": "2단계 인증이 활성화되었습니다",
- "totp_disabled_success": "2단계 인증이 비활성화되었습니다",
- "totp_setup_failed": "2FA 설정을 시작할 수 없습니다",
- "totp_required": "2단계 인증 필요",
- "totp_wrong_code": "잘못된 2FA 코드"
-}
diff --git a/betterdesk-support-agent/locales/nb.json b/betterdesk-support-agent/locales/nb.json
deleted file mode 100644
index 29ad4a6e..00000000
--- a/betterdesk-support-agent/locales/nb.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Støtte",
- "your_id": "ID-en din",
- "access_password": "Passord",
- "show": "Vis",
- "hide": "Skjul",
- "copy": "Kopier",
- "copied": "Kopiert til utklippstavlen",
- "regenerate": "Generer ny",
- "password_regenerated": "Nytt passord generert",
- "set_custom": "Angi egendefinert passord",
- "custom_password": "Egendefinert passord",
- "access_mode": "Tilgangsmodus",
- "mode_supervised": "Spør hver gang",
- "mode_unattended": "Uovervåket tilgang",
- "mode_disabled": "Deaktivert",
- "receive_support": "Få støtte",
- "receive_support_hint": "Be om hjelp eller chat med support. Du kan også dele ID og passord nedenfor.",
- "or_share_id": "Eller del ID-en din",
- "share_id_password": "Del ID og passord",
- "share_id_hint": "Del disse opplysningene slik at support kan koble til enheten din.",
- "ongoing_session": "Pågående økt",
- "close_session": "Lukk økt",
- "request_help": "Be om hjelp",
- "help_message": "Beskriv problemet ditt",
- "send": "Send melding",
- "cancel": "Avbryt",
- "help_sent": "Hjelpeforespørsel sendt",
- "help_failed": "Kunne ikke sende hjelpeforespørsel",
- "connected": "Tilkoblet",
- "disconnected": "Kobler til...",
- "save": "Lagre",
- "settings": "Innstillinger",
- "test_connection": "Test tilkobling",
- "test_running": "Tester tilkobling…",
- "test_ok": "Tilkobling OK",
- "test_failed": "Tilkoblingsproblem",
- "test_gateway": "Remote-gateway",
- "test_api": "Administrasjons-API",
- "test_enrollment": "Enhetsregistrerings-API",
- "close": "Lukk",
- "password_too_short": "Passordet må være minst 6 tegn",
- "status_ready": "Klar til å koble til (sikker tilkobling)",
- "enrollment_pending": "Venter",
- "enrollment_rejected": "Avvist",
- "enrollment_error": "Registrering mislyktes",
- "consent_title": "Ekspertinformasjon",
- "consent_prompt": "Tillat tilkobling fra",
- "consent_ack": "Jeg har lest ekspertinformasjonen.",
- "consent_display_name": "Visningsnavn",
- "consent_session": "Økt",
- "session_active": "Aktiv økt",
- "session_with": "Økt med",
- "session_disconnect": "Skjul øktlinje",
- "disconnect": "Koble fra",
- "chat_with_support": "Chat med støtte",
- "settings_language": "Språk",
- "quit": "Avslutt",
- "consent_accept": "Fortsett",
- "consent_deny": "Avbryt økt",
- "chat_title": "Samtale",
- "chat_send": "Send melding",
- "chat_placeholder": "Skriv inn en melding...",
- "chat_empty": "Ingen meldinger ennå",
- "totp_title": "Tofaktorautentisering",
- "totp_enabled": "2FA er aktivert på denne enheten",
- "totp_disabled": "2FA er ikke aktivert",
- "totp_setup": "Konfigurer 2FA",
- "totp_disable": "Deaktiver 2FA",
- "totp_manual_key": "Manuell nøkkel",
- "totp_step2": "Skann URI i autentiseringsappen og skriv inn koden",
- "totp_enter_code": "Skriv inn bekreftelseskode",
- "totp_verify_enable": "Bekreft og aktiver",
- "totp_invalid_code": "Ugyldig bekreftelseskode",
- "totp_enabled_success": "Tofaktorautentisering aktivert",
- "totp_disabled_success": "Tofaktorautentisering deaktivert",
- "totp_setup_failed": "Kunne ikke starte 2FA-oppsett",
- "totp_required": "Tofaktorautentisering kreves",
- "totp_wrong_code": "Ugyldig 2FA-kode"
-}
diff --git a/betterdesk-support-agent/locales/nl.json b/betterdesk-support-agent/locales/nl.json
deleted file mode 100644
index f1a344b4..00000000
--- a/betterdesk-support-agent/locales/nl.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Ondersteuning",
- "your_id": "Uw ID",
- "access_password": "Wachtwoord",
- "show": "Tonen",
- "hide": "Verbergen",
- "copy": "Kopiëren",
- "copied": "Gekopieerd naar klembord",
- "regenerate": "Nieuw genereren",
- "password_regenerated": "Nieuw wachtwoord gegenereerd",
- "set_custom": "Aangepast wachtwoord instellen",
- "custom_password": "Aangepast wachtwoord",
- "access_mode": "Toegangsmodus",
- "mode_supervised": "Elke keer vragen",
- "mode_unattended": "Onbeheerde toegang",
- "mode_disabled": "Uitgeschakeld",
- "receive_support": "Ondersteuning ontvangen",
- "receive_support_hint": "Vraag hulp of chat met support. U kunt ook uw ID en wachtwoord hieronder delen.",
- "or_share_id": "Of deel uw ID",
- "share_id_password": "ID en wachtwoord delen",
- "share_id_hint": "Deel deze gegevens zodat support verbinding kan maken met uw apparaat.",
- "ongoing_session": "Lopende sessie",
- "close_session": "Sessie sluiten",
- "request_help": "Hulp vragen",
- "help_message": "Beschrijf uw probleem",
- "send": "Verzenden",
- "cancel": "Annuleren",
- "help_sent": "Hulpverzoek verzonden",
- "help_failed": "Kon hulpverzoek niet verzenden",
- "connected": "Verbonden",
- "disconnected": "Verbinden...",
- "save": "Opslaan",
- "settings": "Instellingen",
- "test_connection": "Verbinding testen",
- "test_running": "Verbinding testen…",
- "test_ok": "Verbinding OK",
- "test_failed": "Verbindingsprobleem",
- "test_gateway": "Remote-gateway",
- "test_api": "Beheer-API",
- "test_enrollment": "Apparaatregistratie-API",
- "close": "Sluiten",
- "password_too_short": "Wachtwoord moet minimaal 6 tekens bevatten",
- "status_ready": "Klaar om te verbinden (beveiligde verbinding)",
- "enrollment_pending": "Wachtend",
- "enrollment_rejected": "Afgewezen",
- "enrollment_error": "Registratie mislukt",
- "consent_title": "Expertinformatie",
- "consent_prompt": "Verbinding toestaan van",
- "consent_ack": "Ik heb de expertinformatie gelezen.",
- "consent_display_name": "Weergavenaam",
- "consent_session": "Sessie",
- "session_active": "Actieve sessie",
- "session_with": "Sessie met",
- "session_disconnect": "Sessiebalk verbergen",
- "disconnect": "Verbinding verbreken",
- "chat_with_support": "Chatten met ondersteuning",
- "settings_language": "Taal",
- "quit": "Afsluiten",
- "consent_accept": "Doorgaan",
- "consent_deny": "Sessie annuleren",
- "chat_title": "Gesprek",
- "chat_send": "Verzenden",
- "chat_placeholder": "Typ een bericht...",
- "chat_empty": "Nog geen berichten",
- "totp_title": "Tweefactorauthenticatie",
- "totp_enabled": "2FA is ingeschakeld op dit apparaat",
- "totp_disabled": "2FA is niet ingeschakeld",
- "totp_setup": "2FA instellen",
- "totp_disable": "2FA uitschakelen",
- "totp_manual_key": "Handmatige sleutel",
- "totp_step2": "Scan de URI in uw authenticator-app en voer de code in",
- "totp_enter_code": "Voer verificatiecode in",
- "totp_verify_enable": "Verifiëren en inschakelen",
- "totp_invalid_code": "Ongeldige verificatiecode",
- "totp_enabled_success": "Tweefactorauthenticatie ingeschakeld",
- "totp_disabled_success": "Tweefactorauthenticatie uitgeschakeld",
- "totp_setup_failed": "Kon 2FA-instelling niet starten",
- "totp_required": "Tweefactorauthenticatie vereist",
- "totp_wrong_code": "Ongeldige 2FA-code"
-}
diff --git a/betterdesk-support-agent/locales/pl.json b/betterdesk-support-agent/locales/pl.json
deleted file mode 100644
index 76377ea1..00000000
--- a/betterdesk-support-agent/locales/pl.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Wsparcie",
- "your_id": "Twoje ID",
- "access_password": "Hasło",
- "show": "Pokaż",
- "hide": "Ukryj",
- "copy": "Kopiuj",
- "copied": "Skopiowano do schowka",
- "regenerate": "Generuj nowe",
- "password_regenerated": "Wygenerowano nowe hasło",
- "set_custom": "Ustaw własne hasło",
- "custom_password": "Własne hasło",
- "access_mode": "Tryb dostępu",
- "mode_supervised": "Pytaj za każdym razem",
- "mode_unattended": "Dostęp bez nadzoru",
- "mode_disabled": "Wyłączony",
- "receive_support": "Uzyskaj pomoc",
- "receive_support_hint": "Wyślij prośbę o pomoc lub czat ze wsparciem. Możesz też udostępnić ID i hasło poniżej.",
- "or_share_id": "Lub udostępnij swoje ID",
- "share_id_password": "Udostępnij ID i hasło",
- "share_id_hint": "Udostępnij te dane, aby osoba wspierająca mogła połączyć się z Twoim urządzeniem.",
- "ongoing_session": "Trwająca sesja",
- "close_session": "Zamknij sesję",
- "request_help": "Poproś o pomoc",
- "help_message": "Opisz swój problem",
- "send": "Wyślij",
- "cancel": "Anuluj",
- "help_sent": "Wysłano prośbę o pomoc",
- "help_failed": "Nie udało się wysłać prośby o pomoc",
- "connected": "Połączony",
- "disconnected": "Łączenie...",
- "save": "Zapisz",
- "settings": "Ustawienia",
- "test_connection": "Testuj połączenie",
- "test_running": "Testowanie połączenia…",
- "test_ok": "Połączenie poprawne",
- "test_failed": "Problem z połączeniem",
- "test_gateway": "Brama zdalna",
- "test_api": "API serwera",
- "test_enrollment": "API rejestracji urządzenia",
- "close": "Zamknij",
- "password_too_short": "Hasło musi mieć co najmniej 6 znaków",
- "status_ready": "Gotowy do połączenia (bezpieczne połączenie)",
- "enrollment_pending": "Oczekujące",
- "enrollment_rejected": "Odrzucone",
- "enrollment_error": "Rejestracja nie powiodła się",
- "consent_title": "Informacje o ekspercie",
- "consent_prompt": "Zezwolić na połączenie od",
- "consent_ack": "Przeczytałem informacje o ekspercie.",
- "consent_display_name": "Nazwa wyświetlana",
- "consent_session": "Sesja",
- "session_active": "Aktywna sesja",
- "session_with": "Sesja z",
- "session_disconnect": "Ukryj pasek sesji",
- "disconnect": "Rozłącz",
- "chat_with_support": "Czat ze wsparciem",
- "settings_language": "Język",
- "quit": "Zakończ",
- "consent_accept": "Kontynuuj",
- "consent_deny": "Anuluj sesję",
- "chat_title": "Czat",
- "chat_send": "Wyślij",
- "chat_placeholder": "Wpisz wiadomość...",
- "chat_empty": "Brak wiadomości",
- "totp_title": "Uwierzytelnianie dwuskładnikowe",
- "totp_enabled": "2FA włączone na tym urządzeniu",
- "totp_disabled": "2FA wyłączone",
- "totp_setup": "Skonfiguruj 2FA",
- "totp_disable": "Wyłącz 2FA",
- "totp_manual_key": "Klucz ręczny",
- "totp_step2": "Zeskanuj URI w aplikacji uwierzytelniającej, potem wpisz kod",
- "totp_enter_code": "Wprowadź kod weryfikacyjny",
- "totp_verify_enable": "Zweryfikuj i włącz",
- "totp_invalid_code": "Nieprawidłowy kod weryfikacyjny",
- "totp_enabled_success": "Uwierzytelnianie dwuskładnikowe włączone",
- "totp_disabled_success": "Uwierzytelnianie dwuskładnikowe wyłączone",
- "totp_setup_failed": "Nie udało się rozpocząć konfiguracji 2FA",
- "totp_required": "Wymagane uwierzytelnianie dwuskładnikowe",
- "totp_wrong_code": "Nieprawidłowy kod 2FA"
-}
diff --git a/betterdesk-support-agent/locales/pt.json b/betterdesk-support-agent/locales/pt.json
deleted file mode 100644
index 22146d75..00000000
--- a/betterdesk-support-agent/locales/pt.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Suporte",
- "your_id": "Seu ID",
- "access_password": "Senha",
- "show": "Mostrar",
- "hide": "Ocultar",
- "copy": "Copiar",
- "copied": "Copiado para a área de transferência",
- "regenerate": "Gerar novo",
- "password_regenerated": "Nova palavra-passe gerada",
- "set_custom": "Definir palavra-passe personalizada",
- "custom_password": "Palavra-passe personalizada",
- "access_mode": "Modo de acesso",
- "mode_supervised": "Perguntar sempre",
- "mode_unattended": "Acesso autónomo",
- "mode_disabled": "Desativado",
- "receive_support": "Receber suporte",
- "receive_support_hint": "Peça ajuda ou converse com o suporte. Também pode partilhar o seu ID e palavra-passe abaixo.",
- "or_share_id": "Ou partilhe o seu ID",
- "share_id_password": "Partilhar ID e palavra-passe",
- "share_id_hint": "Partilhe estas credenciais para que o suporte se possa ligar ao seu dispositivo.",
- "ongoing_session": "Sessão em curso",
- "close_session": "Fechar sessão",
- "request_help": "Solicitar ajuda",
- "help_message": "Descreva o seu problema",
- "send": "Enviar",
- "cancel": "Cancelar",
- "help_sent": "Pedido de ajuda enviado",
- "help_failed": "Não foi possível enviar o pedido de ajuda",
- "connected": "Conectado",
- "disconnected": "A conectar...",
- "save": "Salvar",
- "settings": "Configurações",
- "test_connection": "Testar ligação",
- "test_running": "A testar ligação…",
- "test_ok": "Ligação OK",
- "test_failed": "Problema de ligação",
- "test_gateway": "Gateway remoto",
- "test_api": "API de gestão",
- "test_enrollment": "API de registo do dispositivo",
- "close": "Fechar",
- "password_too_short": "A palavra-passe deve ter pelo menos 6 caracteres",
- "status_ready": "Pronto para ligar (ligação segura)",
- "enrollment_pending": "Pendente",
- "enrollment_rejected": "Rejeitado",
- "enrollment_error": "Registo falhou",
- "consent_title": "Informações do especialista",
- "consent_prompt": "Permitir ligação de",
- "consent_ack": "Li as informações do especialista.",
- "consent_display_name": "Nome apresentado",
- "consent_session": "Sessão",
- "session_active": "Sessão ativa",
- "session_with": "Sessão com",
- "session_disconnect": "Ocultar barra de sessão",
- "disconnect": "Desligar",
- "chat_with_support": "Conversar com o suporte",
- "settings_language": "Idioma",
- "quit": "Sair",
- "consent_accept": "Continuar",
- "consent_deny": "Cancelar sessão",
- "chat_title": "Conversa",
- "chat_send": "Enviar",
- "chat_placeholder": "Digite uma mensagem...",
- "chat_empty": "Nenhuma mensagem ainda",
- "totp_title": "Autenticação de dois fatores",
- "totp_enabled": "2FA ativada neste dispositivo",
- "totp_disabled": "2FA não ativada",
- "totp_setup": "Configurar 2FA",
- "totp_disable": "Desativar 2FA",
- "totp_manual_key": "Chave manual",
- "totp_step2": "Digitalize o URI na aplicação autenticadora e introduza o código",
- "totp_enter_code": "Introduza o código de verificação",
- "totp_verify_enable": "Verificar e ativar",
- "totp_invalid_code": "Código de verificação inválido",
- "totp_enabled_success": "Autenticação de dois fatores ativada",
- "totp_disabled_success": "Autenticação de dois fatores desativada",
- "totp_setup_failed": "Não foi possível iniciar a configuração 2FA",
- "totp_required": "Autenticação de dois fatores necessária",
- "totp_wrong_code": "Código 2FA inválido"
-}
diff --git a/betterdesk-support-agent/locales/ro.json b/betterdesk-support-agent/locales/ro.json
deleted file mode 100644
index 68791b3e..00000000
--- a/betterdesk-support-agent/locales/ro.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Asistență",
- "your_id": "ID-ul dvs",
- "access_password": "Parolă",
- "show": "Afișează",
- "hide": "Ascunde",
- "copy": "Copiază",
- "copied": "Copiat în clipboard",
- "regenerate": "Generează nou",
- "password_regenerated": "Parolă nouă generată",
- "set_custom": "Setează parolă personalizată",
- "custom_password": "Parolă personalizată",
- "access_mode": "Mod de acces",
- "mode_supervised": "Întreabă de fiecare dată",
- "mode_unattended": "Acces nesupravegheat",
- "mode_disabled": "Dezactivat",
- "receive_support": "Primește asistență",
- "receive_support_hint": "Solicită ajutor sau discută cu suportul. Poți partaja și ID-ul și parola mai jos.",
- "or_share_id": "Sau partajează ID-ul",
- "share_id_password": "Partajează ID-ul și parola",
- "share_id_hint": "Partajează aceste date pentru ca suportul să se poată conecta la dispozitiv.",
- "ongoing_session": "Sesiune în curs",
- "close_session": "Închide sesiunea",
- "request_help": "Solicită ajutor",
- "help_message": "Descrieți problema",
- "send": "Trimite",
- "cancel": "Anulează",
- "help_sent": "Cerere de ajutor trimisă",
- "help_failed": "Nu s-a putut trimite cererea de ajutor",
- "connected": "Conectat",
- "disconnected": "Se conectează...",
- "save": "Salvați",
- "settings": "Setări",
- "test_connection": "Testează conexiunea",
- "test_running": "Se testează conexiunea…",
- "test_ok": "Conexiune OK",
- "test_failed": "Problemă de conexiune",
- "test_gateway": "Gateway remote",
- "test_api": "API de administrare",
- "test_enrollment": "API înregistrare dispozitiv",
- "close": "Închide",
- "password_too_short": "Parola trebuie să aibă cel puțin 6 caractere",
- "status_ready": "Gata de conectare (conexiune securizată)",
- "enrollment_pending": "În așteptare",
- "enrollment_rejected": "Respins",
- "enrollment_error": "Înregistrare eșuată",
- "consent_title": "Informații expert",
- "consent_prompt": "Permite conexiunea de la",
- "consent_ack": "Am citit informațiile despre expert.",
- "consent_display_name": "Nume afișat",
- "consent_session": "Sesiune",
- "session_active": "Sesiune activă",
- "session_with": "Sesiune cu",
- "session_disconnect": "Ascunde bara de sesiune",
- "disconnect": "Deconectează",
- "chat_with_support": "Chat cu suport",
- "settings_language": "Limba",
- "quit": "Ieșire",
- "consent_accept": "Continuă",
- "consent_deny": "Anulează sesiunea",
- "chat_title": "Conversație",
- "chat_send": "Trimite",
- "chat_placeholder": "Tastați un mesaj...",
- "chat_empty": "Încă nu există mesaje",
- "totp_title": "Autentificare cu doi factori",
- "totp_enabled": "2FA este activată pe acest dispozitiv",
- "totp_disabled": "2FA nu este activată",
- "totp_setup": "Configurează 2FA",
- "totp_disable": "Dezactivează 2FA",
- "totp_manual_key": "Cheie manuală",
- "totp_step2": "Scanați URI-ul în aplicația de autentificare, apoi introduceți codul",
- "totp_enter_code": "Introduceți codul de verificare",
- "totp_verify_enable": "Verifică și activează",
- "totp_invalid_code": "Cod de verificare invalid",
- "totp_enabled_success": "Autentificare cu doi factori activată",
- "totp_disabled_success": "Autentificare cu doi factori dezactivată",
- "totp_setup_failed": "Nu s-a putut porni configurarea 2FA",
- "totp_required": "Autentificare cu doi factori necesară",
- "totp_wrong_code": "Cod 2FA invalid"
-}
diff --git a/betterdesk-support-agent/locales/supplemental.json b/betterdesk-support-agent/locales/supplemental.json
deleted file mode 100644
index a5a70020..00000000
--- a/betterdesk-support-agent/locales/supplemental.json
+++ /dev/null
@@ -1,1172 +0,0 @@
-{
- "en": {
- "window_title": "Support",
- "show": "Show",
- "hide": "Hide",
- "regenerate": "Generate new",
- "set_custom": "Set custom password",
- "custom_password": "Custom password",
- "access_mode": "Access mode",
- "mode_supervised": "Ask each time",
- "mode_unattended": "Unattended access",
- "mode_disabled": "Disabled",
- "help_message": "Describe your problem",
- "help_sent": "Help request sent",
- "help_failed": "Could not send help request",
- "test_connection": "Test connection",
- "test_running": "Testing connection…",
- "test_ok": "Connection OK",
- "test_failed": "Connection problem",
- "test_gateway": "Remote gateway",
- "test_api": "Management API",
- "test_enrollment": "Device registration API",
- "password_too_short": "Password must be at least 6 characters",
- "enrollment_error": "Registration failed",
- "consent_title": "Remote access request",
- "consent_prompt": "Allow connection from",
- "session_active": "Active session",
- "session_with": "Session with",
- "session_disconnect": "Hide session bar",
- "quit": "Quit",
- "totp_title": "Two-factor authentication",
- "totp_enabled": "2FA is enabled for this device",
- "totp_disabled": "2FA is not enabled",
- "totp_setup": "Set up 2FA",
- "totp_disable": "Disable 2FA",
- "totp_manual_key": "Manual key",
- "totp_step2": "Scan the URI in your authenticator app, then enter the code",
- "totp_enter_code": "Enter verification code",
- "totp_verify_enable": "Verify and enable",
- "totp_invalid_code": "Invalid verification code",
- "totp_enabled_success": "Two-factor authentication enabled",
- "totp_disabled_success": "Two-factor authentication disabled",
- "totp_setup_failed": "Could not start 2FA setup",
- "totp_required": "Two-factor authentication required",
- "totp_wrong_code": "Invalid two-factor code"
- },
- "pl": {
- "window_title": "Wsparcie",
- "show": "Pokaż",
- "hide": "Ukryj",
- "regenerate": "Generuj nowe",
- "set_custom": "Ustaw własne hasło",
- "custom_password": "Własne hasło",
- "access_mode": "Tryb dostępu",
- "mode_supervised": "Pytaj za każdym razem",
- "mode_unattended": "Dostęp bez nadzoru",
- "mode_disabled": "Wyłączony",
- "help_message": "Opisz swój problem",
- "help_sent": "Wysłano prośbę o pomoc",
- "help_failed": "Nie udało się wysłać prośby o pomoc",
- "test_connection": "Testuj połączenie",
- "test_running": "Testowanie połączenia…",
- "test_ok": "Połączenie poprawne",
- "test_failed": "Problem z połączeniem",
- "test_gateway": "Brama zdalna",
- "test_api": "API serwera",
- "test_enrollment": "API rejestracji urządzenia",
- "password_too_short": "Hasło musi mieć co najmniej 6 znaków",
- "enrollment_error": "Rejestracja nie powiodła się",
- "consent_title": "Prośba o zdalny dostęp",
- "consent_prompt": "Zezwolić na połączenie od",
- "session_active": "Aktywna sesja",
- "session_with": "Sesja z",
- "session_disconnect": "Ukryj pasek sesji",
- "quit": "Zakończ",
- "totp_title": "Uwierzytelnianie dwuskładnikowe",
- "totp_enabled": "2FA włączone na tym urządzeniu",
- "totp_disabled": "2FA wyłączone",
- "totp_setup": "Skonfiguruj 2FA",
- "totp_disable": "Wyłącz 2FA",
- "totp_manual_key": "Klucz ręczny",
- "totp_step2": "Zeskanuj URI w aplikacji uwierzytelniającej, potem wpisz kod",
- "totp_enter_code": "Wprowadź kod weryfikacyjny",
- "totp_verify_enable": "Zweryfikuj i włącz",
- "totp_invalid_code": "Nieprawidłowy kod weryfikacyjny",
- "totp_enabled_success": "Uwierzytelnianie dwuskładnikowe włączone",
- "totp_disabled_success": "Uwierzytelnianie dwuskładnikowe wyłączone",
- "totp_setup_failed": "Nie udało się rozpocząć konfiguracji 2FA",
- "totp_required": "Wymagane uwierzytelnianie dwuskładnikowe",
- "totp_wrong_code": "Nieprawidłowy kod 2FA"
- },
- "ar": {
- "window_title": "الدعم",
- "show": "إظهار",
- "hide": "إخفاء",
- "regenerate": "إنشاء جديد",
- "set_custom": "تعيين كلمة مرور مخصصة",
- "custom_password": "كلمة مرور مخصصة",
- "access_mode": "وضع الوصول",
- "mode_supervised": "السؤال في كل مرة",
- "mode_unattended": "وصول بدون إشراف",
- "mode_disabled": "معطّل",
- "help_message": "صف مشكلتك",
- "help_sent": "تم إرسال طلب المساعدة",
- "help_failed": "تعذّر إرسال طلب المساعدة",
- "test_connection": "اختبار الاتصال",
- "test_running": "جارٍ اختبار الاتصال…",
- "test_ok": "الاتصال سليم",
- "test_failed": "مشكلة في الاتصال",
- "test_gateway": "بوابة بعيدة",
- "test_api": "واجهة الإدارة",
- "test_enrollment": "واجهة تسجيل الجهاز",
- "password_too_short": "يجب أن تتكون كلمة المرور من 6 أحرف على الأقل",
- "enrollment_error": "فشل التسجيل",
- "consent_title": "طلب وصول عن بُعد",
- "consent_prompt": "السماح بالاتصال من",
- "session_active": "جلسة نشطة",
- "session_with": "جلسة مع",
- "session_disconnect": "إخفاء شريط الجلسة",
- "quit": "إنهاء",
- "totp_title": "المصادقة الثنائية",
- "totp_enabled": "تم تفعيل 2FA على هذا الجهاز",
- "totp_disabled": "2FA غير مفعّل",
- "totp_setup": "إعداد 2FA",
- "totp_disable": "تعطيل 2FA",
- "totp_manual_key": "مفتاح يدوي",
- "totp_step2": "امسح URI في تطبيق المصادقة ثم أدخل الرمز",
- "totp_enter_code": "أدخل رمز التحقق",
- "totp_verify_enable": "تحقق وفعّل",
- "totp_invalid_code": "رمز تحقق غير صالح",
- "totp_enabled_success": "تم تفعيل المصادقة الثنائية",
- "totp_disabled_success": "تم تعطيل المصادقة الثنائية",
- "totp_setup_failed": "تعذّر بدء إعداد 2FA",
- "totp_required": "المصادقة الثنائية مطلوبة",
- "totp_wrong_code": "رمز 2FA غير صالح"
- },
- "cs": {
- "window_title": "Podpora",
- "show": "Zobrazit",
- "hide": "Skrýt",
- "regenerate": "Generovat nové",
- "set_custom": "Nastavit vlastní heslo",
- "custom_password": "Vlastní heslo",
- "access_mode": "Režim přístupu",
- "mode_supervised": "Ptát se pokaždé",
- "mode_unattended": "Bez dozoru",
- "mode_disabled": "Zakázáno",
- "help_message": "Popište svůj problém",
- "help_sent": "Žádost o pomoc odeslána",
- "help_failed": "Nepodařilo se odeslat žádost o pomoc",
- "test_connection": "Test připojení",
- "test_running": "Testování připojení…",
- "test_ok": "Připojení v pořádku",
- "test_failed": "Problém s připojením",
- "test_gateway": "Vzdálená brána",
- "test_api": "Správcovské API",
- "test_enrollment": "API registrace zařízení",
- "password_too_short": "Heslo musí mít alespoň 6 znaků",
- "enrollment_error": "Registrace se nezdařila",
- "consent_title": "Žádost o vzdálený přístup",
- "consent_prompt": "Povolit připojení od",
- "session_active": "Aktivní relace",
- "session_with": "Relace s",
- "session_disconnect": "Skrýt panel relace",
- "quit": "Ukončit",
- "totp_title": "Dvoufaktorové ověření",
- "totp_enabled": "2FA je na tomto zařízení povoleno",
- "totp_disabled": "2FA není povoleno",
- "totp_setup": "Nastavit 2FA",
- "totp_disable": "Zakázat 2FA",
- "totp_manual_key": "Ruční klíč",
- "totp_step2": "Naskenujte URI v autentizační aplikaci a zadejte kód",
- "totp_enter_code": "Zadejte ověřovací kód",
- "totp_verify_enable": "Ověřit a povolit",
- "totp_invalid_code": "Neplatný ověřovací kód",
- "totp_enabled_success": "Dvoufaktorové ověření povoleno",
- "totp_disabled_success": "Dvoufaktorové ověření zakázáno",
- "totp_setup_failed": "Nepodařilo se spustit nastavení 2FA",
- "totp_required": "Vyžadováno dvoufaktorové ověření",
- "totp_wrong_code": "Neplatný kód 2FA"
- },
- "da": {
- "window_title": "Support",
- "show": "Vis",
- "hide": "Skjul",
- "regenerate": "Generer ny",
- "set_custom": "Angiv brugerdefineret adgangskode",
- "custom_password": "Brugerdefineret adgangskode",
- "access_mode": "Adgangstilstand",
- "mode_supervised": "Spørg hver gang",
- "mode_unattended": "Uovervåget adgang",
- "mode_disabled": "Deaktiveret",
- "help_message": "Beskriv dit problem",
- "help_sent": "Hjælpeanmodning sendt",
- "help_failed": "Kunne ikke sende hjælpeanmodning",
- "test_connection": "Test forbindelse",
- "test_running": "Tester forbindelse…",
- "test_ok": "Forbindelse OK",
- "test_failed": "Forbindelsesproblem",
- "test_gateway": "Remote-gateway",
- "test_api": "Administrations-API",
- "test_enrollment": "Enhedsregistrerings-API",
- "password_too_short": "Adgangskode skal være mindst 6 tegn",
- "enrollment_error": "Registrering mislykkedes",
- "consent_title": "Anmodning om fjernadgang",
- "consent_prompt": "Tillad forbindelse fra",
- "session_active": "Aktiv session",
- "session_with": "Session med",
- "session_disconnect": "Skjul sessionslinje",
- "quit": "Afslut",
- "totp_title": "To-faktor-godkendelse",
- "totp_enabled": "2FA er aktiveret på denne enhed",
- "totp_disabled": "2FA er ikke aktiveret",
- "totp_setup": "Konfigurer 2FA",
- "totp_disable": "Deaktiver 2FA",
- "totp_manual_key": "Manuel nøgle",
- "totp_step2": "Scan URI i din godkendelsesapp, og indtast koden",
- "totp_enter_code": "Indtast bekræftelseskode",
- "totp_verify_enable": "Bekræft og aktiver",
- "totp_invalid_code": "Ugyldig bekræftelseskode",
- "totp_enabled_success": "To-faktor-godkendelse aktiveret",
- "totp_disabled_success": "To-faktor-godkendelse deaktiveret",
- "totp_setup_failed": "Kunne ikke starte 2FA-opsætning",
- "totp_required": "To-faktor-godkendelse påkrævet",
- "totp_wrong_code": "Ugyldig 2FA-kode"
- },
- "de": {
- "window_title": "Support",
- "show": "Anzeigen",
- "hide": "Ausblenden",
- "regenerate": "Neu generieren",
- "set_custom": "Eigenes Passwort festlegen",
- "custom_password": "Eigenes Passwort",
- "access_mode": "Zugriffsmodus",
- "mode_supervised": "Jedes Mal fragen",
- "mode_unattended": "Unbeaufsichtigter Zugriff",
- "mode_disabled": "Deaktiviert",
- "help_message": "Beschreiben Sie Ihr Problem",
- "help_sent": "Hilfeanfrage gesendet",
- "help_failed": "Hilfeanfrage konnte nicht gesendet werden",
- "test_connection": "Verbindung testen",
- "test_running": "Verbindung wird getestet…",
- "test_ok": "Verbindung OK",
- "test_failed": "Verbindungsproblem",
- "test_gateway": "Remote-Gateway",
- "test_api": "Verwaltungs-API",
- "test_enrollment": "Geräteregistrierungs-API",
- "password_too_short": "Passwort muss mindestens 6 Zeichen haben",
- "enrollment_error": "Registrierung fehlgeschlagen",
- "consent_title": "Anfrage für Fernzugriff",
- "consent_prompt": "Verbindung zulassen von",
- "session_active": "Aktive Sitzung",
- "session_with": "Sitzung mit",
- "session_disconnect": "Sitzungsleiste ausblenden",
- "quit": "Beenden",
- "totp_title": "Zwei-Faktor-Authentifizierung",
- "totp_enabled": "2FA ist für dieses Gerät aktiviert",
- "totp_disabled": "2FA ist nicht aktiviert",
- "totp_setup": "2FA einrichten",
- "totp_disable": "2FA deaktivieren",
- "totp_manual_key": "Manueller Schlüssel",
- "totp_step2": "Scannen Sie die URI in Ihrer Authenticator-App und geben Sie den Code ein",
- "totp_enter_code": "Bestätigungscode",
- "totp_verify_enable": "Verifizieren und aktivieren",
- "totp_invalid_code": "Ungültiger Verifizierungscode",
- "totp_enabled_success": "Zwei-Faktor-Authentifizierung aktiviert",
- "totp_disabled_success": "Zwei-Faktor-Authentifizierung deaktiviert",
- "totp_setup_failed": "2FA-Einrichtung konnte nicht gestartet werden",
- "totp_required": "Zwei-Faktor-Authentifizierung erforderlich",
- "totp_wrong_code": "Ungültiger 2FA-Code"
- },
- "es": {
- "window_title": "Soporte",
- "show": "Show",
- "hide": "Hide",
- "regenerate": "Generar nuevo",
- "set_custom": "Establecer contraseña personalizada",
- "custom_password": "Contraseña personalizada",
- "access_mode": "Modo de acceso",
- "mode_supervised": "Preguntar cada vez",
- "mode_unattended": "Acceso desatendido",
- "mode_disabled": "Desactivado",
- "help_message": "Describa su problema",
- "help_sent": "Solicitud de ayuda enviada",
- "help_failed": "No se pudo enviar la solicitud de ayuda",
- "test_connection": "Test connection",
- "test_running": "Cargando...",
- "test_ok": "Éxito",
- "test_failed": "Error",
- "test_gateway": "Puerta de enlace remota",
- "test_api": "API de gestión",
- "test_enrollment": "API de registro del dispositivo",
- "password_too_short": "Password must be at least 6 characters",
- "enrollment_error": "Error en el registro",
- "consent_title": "Solicitud de acceso remoto",
- "consent_prompt": "Permitir conexión de",
- "session_active": "Sesión activa",
- "session_with": "Sesión con",
- "session_disconnect": "Ocultar barra de sesión",
- "quit": "Salir",
- "totp_title": "Autenticación de dos factores",
- "totp_enabled": "2FA activada en este dispositivo",
- "totp_disabled": "2FA no activada",
- "totp_setup": "Configurar 2FA",
- "totp_disable": "Desactivar 2FA",
- "totp_manual_key": "Clave manual",
- "totp_step2": "Escanee la URI en su aplicación de autenticación e introduzca el código",
- "totp_enter_code": "Código de verificación",
- "totp_verify_enable": "Verificar y activar",
- "totp_invalid_code": "Código de verificación no válido",
- "totp_enabled_success": "Autenticación de dos factores activada",
- "totp_disabled_success": "Autenticación de dos factores desactivada",
- "totp_setup_failed": "No se pudo iniciar la configuración 2FA",
- "totp_required": "Se requiere autenticación de dos factores",
- "totp_wrong_code": "Código 2FA no válido"
- },
- "fi": {
- "window_title": "Tuki",
- "show": "Näytä",
- "hide": "Piilota",
- "regenerate": "Luo uusi",
- "set_custom": "Aseta mukautettu salasana",
- "custom_password": "Mukautettu salasana",
- "access_mode": "Käyttötila",
- "mode_supervised": "Kysy joka kerta",
- "mode_unattended": "Valvomaton käyttö",
- "mode_disabled": "Poissa käytöstä",
- "help_message": "Kuvaile ongelmasi",
- "help_sent": "Apupyyntö lähetetty",
- "help_failed": "Apupyyntöä ei voitu lähettää",
- "test_connection": "Testaa yhteys",
- "test_running": "Testataan yhteyttä…",
- "test_ok": "Yhteys kunnossa",
- "test_failed": "Yhteysongelma",
- "test_gateway": "Etäyhdyskäytävä",
- "test_api": "Hallinta-API",
- "test_enrollment": "Laitteen rekisteröinti-API",
- "password_too_short": "Salasanassa oltava vähintään 6 merkkiä",
- "enrollment_error": "Rekisteröinti epäonnistui",
- "consent_title": "Etäkäyttöpyyntö",
- "consent_prompt": "Salli yhteys käyttäjältä",
- "session_active": "Aktiivinen istunto",
- "session_with": "Istunto:",
- "session_disconnect": "Piilota istuntopalkki",
- "quit": "Lopeta",
- "totp_title": "Kaksivaiheinen tunnistus",
- "totp_enabled": "2FA on käytössä tällä laitteella",
- "totp_disabled": "2FA ei ole käytössä",
- "totp_setup": "Määritä 2FA",
- "totp_disable": "Poista 2FA käytöstä",
- "totp_manual_key": "Manuaalinen avain",
- "totp_step2": "Skannaa URI tunnistussovelluksessa ja syötä koodi",
- "totp_enter_code": "Syötä vahvistuskoodi",
- "totp_verify_enable": "Vahvista ja ota käyttöön",
- "totp_invalid_code": "Virheellinen vahvistuskoodi",
- "totp_enabled_success": "Kaksivaiheinen tunnistus otettu käyttöön",
- "totp_disabled_success": "Kaksivaiheinen tunnistus poistettu käytöstä",
- "totp_setup_failed": "2FA-määritystä ei voitu aloittaa",
- "totp_required": "Kaksivaiheinen tunnistus vaaditaan",
- "totp_wrong_code": "Virheellinen 2FA-koodi"
- },
- "fr": {
- "window_title": "Assistance",
- "show": "Show",
- "hide": "Hide",
- "regenerate": "Générer un nouveau",
- "set_custom": "Définir un mot de passe personnalisé",
- "custom_password": "Mot de passe personnalisé",
- "access_mode": "Mode d'accès",
- "mode_supervised": "Demander à chaque fois",
- "mode_unattended": "Accès sans surveillance",
- "mode_disabled": "Désactivé",
- "help_message": "Décrivez votre problème",
- "help_sent": "Demande d'aide envoyée",
- "help_failed": "Impossible d'envoyer la demande d'aide",
- "test_connection": "Test connection",
- "test_running": "Chargement...",
- "test_ok": "Succès",
- "test_failed": "Erreur",
- "test_gateway": "Passerelle distante",
- "test_api": "API de gestion",
- "test_enrollment": "API d'enregistrement de l'appareil",
- "password_too_short": "Password must be at least 6 characters",
- "enrollment_error": "Échec de l'enregistrement",
- "consent_title": "Demande d'accès à distance",
- "consent_prompt": "Autoriser la connexion de",
- "session_active": "Session active",
- "session_with": "Session avec",
- "session_disconnect": "Masquer la barre de session",
- "quit": "Quitter",
- "totp_title": "Authentification à deux facteurs",
- "totp_enabled": "2FA activée sur cet appareil",
- "totp_disabled": "2FA non activée",
- "totp_setup": "Configurer la 2FA",
- "totp_disable": "Désactiver la 2FA",
- "totp_manual_key": "Clé manuelle",
- "totp_step2": "Scannez l'URI dans votre application d'authentification, puis saisissez le code",
- "totp_enter_code": "Code de vérification",
- "totp_verify_enable": "Vérifier et activer",
- "totp_invalid_code": "Code de vérification invalide",
- "totp_enabled_success": "Authentification à deux facteurs activée",
- "totp_disabled_success": "Authentification à deux facteurs désactivée",
- "totp_setup_failed": "Impossible de démarrer la configuration 2FA",
- "totp_required": "Authentification à deux facteurs requise",
- "totp_wrong_code": "Code 2FA invalide"
- },
- "hi": {
- "window_title": "सहायता",
- "show": "दिखाएँ",
- "hide": "छिपाएँ",
- "regenerate": "नया बनाएँ",
- "set_custom": "कस्टम पासवर्ड सेट करें",
- "custom_password": "कस्टम पासवर्ड",
- "access_mode": "एक्सेस मोड",
- "mode_supervised": "हर बार पूछें",
- "mode_unattended": "बिना निगरानी एक्सेस",
- "mode_disabled": "अक्षम",
- "help_message": "अपनी समस्या बताएँ",
- "help_sent": "सहायता अनुरोध भेजा गया",
- "help_failed": "सहायता अनुरोध नहीं भेजा जा सका",
- "test_connection": "कनेक्शन परीक्षण",
- "test_running": "कनेक्शन परीक्षण हो रहा है…",
- "test_ok": "कनेक्शन ठीक",
- "test_failed": "कनेक्शन समस्या",
- "test_gateway": "रिमोट गेटवे",
- "test_api": "प्रबंधन API",
- "test_enrollment": "डिवाइस पंजीकरण API",
- "password_too_short": "पासवर्ड कम से कम 6 अक्षर का होना चाहिए",
- "enrollment_error": "पंजीकरण विफल",
- "consent_title": "रिमोट एक्सेस अनुरोध",
- "consent_prompt": "इससे कनेक्शन की अनुमति दें",
- "session_active": "सक्रिय सत्र",
- "session_with": "सत्र:",
- "session_disconnect": "सत्र पट्टी छिपाएँ",
- "quit": "बंद करें",
- "totp_title": "दो-कारक प्रमाणीकरण",
- "totp_enabled": "इस डिवाइस पर 2FA सक्षम है",
- "totp_disabled": "2FA सक्षम नहीं है",
- "totp_setup": "2FA सेट करें",
- "totp_disable": "2FA अक्षम करें",
- "totp_manual_key": "मैन्युअल कुंजी",
- "totp_step2": "प्रमाणक ऐप में URI स्कैन करें, फिर कोड दर्ज करें",
- "totp_enter_code": "सत्यापन कोड दर्ज करें",
- "totp_verify_enable": "सत्यापित करें और सक्षम करें",
- "totp_invalid_code": "अमान्य सत्यापन कोड",
- "totp_enabled_success": "दो-कारक प्रमाणीकरण सक्षम",
- "totp_disabled_success": "दो-कारक प्रमाणीकरण अक्षम",
- "totp_setup_failed": "2FA सेटअप शुरू नहीं हो सका",
- "totp_required": "दो-कारक प्रमाणीकरण आवश्यक",
- "totp_wrong_code": "अमान्य 2FA कोड"
- },
- "hu": {
- "window_title": "Támogatás",
- "show": "Megjelenítés",
- "hide": "Elrejtés",
- "regenerate": "Új generálása",
- "set_custom": "Egyéni jelszó beállítása",
- "custom_password": "Egyéni jelszó",
- "access_mode": "Hozzáférési mód",
- "mode_supervised": "Minden alkalommal kérdezzen",
- "mode_unattended": "Felügyelet nélküli hozzáférés",
- "mode_disabled": "Letiltva",
- "help_message": "Írja le a problémát",
- "help_sent": "Segítségkérés elküldve",
- "help_failed": "Nem sikerült elküldeni a segítségkérést",
- "test_connection": "Kapcsolat tesztelése",
- "test_running": "Kapcsolat tesztelése…",
- "test_ok": "Kapcsolat rendben",
- "test_failed": "Kapcsolati probléma",
- "test_gateway": "Távoli átjáró",
- "test_api": "Kezelő API",
- "test_enrollment": "Eszközregisztrációs API",
- "password_too_short": "A jelszónak legalább 6 karakterből kell állnia",
- "enrollment_error": "Regisztráció sikertelen",
- "consent_title": "Távoli hozzáférési kérelem",
- "consent_prompt": "Kapcsolat engedélyezése:",
- "session_active": "Aktív munkamenet",
- "session_with": "Munkamenet:",
- "session_disconnect": "Munkamenetsáv elrejtése",
- "quit": "Kilépés",
- "totp_title": "Kétfaktoros hitelesítés",
- "totp_enabled": "A 2FA engedélyezve van ezen az eszközön",
- "totp_disabled": "A 2FA nincs engedélyezve",
- "totp_setup": "2FA beállítása",
- "totp_disable": "2FA letiltása",
- "totp_manual_key": "Manuális kulcs",
- "totp_step2": "Olvassa be az URI-t a hitelesítő alkalmazásban, majd adja meg a kódot",
- "totp_enter_code": "Adja meg az ellenőrző kódot",
- "totp_verify_enable": "Ellenőrzés és engedélyezés",
- "totp_invalid_code": "Érvénytelen ellenőrző kód",
- "totp_enabled_success": "Kétfaktoros hitelesítés engedélyezve",
- "totp_disabled_success": "Kétfaktoros hitelesítés letiltva",
- "totp_setup_failed": "A 2FA beállítása nem indítható el",
- "totp_required": "Kétfaktoros hitelesítés szükséges",
- "totp_wrong_code": "Érvénytelen 2FA kód"
- },
- "id": {
- "window_title": "Dukungan",
- "show": "Tampilkan",
- "hide": "Sembunyikan",
- "regenerate": "Buat baru",
- "set_custom": "Atur kata sandi khusus",
- "custom_password": "Kata sandi khusus",
- "access_mode": "Mode akses",
- "mode_supervised": "Tanya setiap kali",
- "mode_unattended": "Akses tanpa pengawasan",
- "mode_disabled": "Nonaktif",
- "help_message": "Jelaskan masalah Anda",
- "help_sent": "Permintaan bantuan terkirim",
- "help_failed": "Tidak dapat mengirim permintaan bantuan",
- "test_connection": "Uji koneksi",
- "test_running": "Menguji koneksi…",
- "test_ok": "Koneksi OK",
- "test_failed": "Masalah koneksi",
- "test_gateway": "Gateway jarak jauh",
- "test_api": "API manajemen",
- "test_enrollment": "API pendaftaran perangkat",
- "password_too_short": "Kata sandi minimal 6 karakter",
- "enrollment_error": "Pendaftaran gagal",
- "consent_title": "Permintaan akses jarak jauh",
- "consent_prompt": "Izinkan koneksi dari",
- "session_active": "Sesi aktif",
- "session_with": "Sesi dengan",
- "session_disconnect": "Sembunyikan bilah sesi",
- "quit": "Keluar",
- "totp_title": "Autentikasi dua faktor",
- "totp_enabled": "2FA diaktifkan di perangkat ini",
- "totp_disabled": "2FA tidak diaktifkan",
- "totp_setup": "Atur 2FA",
- "totp_disable": "Nonaktifkan 2FA",
- "totp_manual_key": "Kunci manual",
- "totp_step2": "Pindai URI di aplikasi autentikator, lalu masukkan kode",
- "totp_enter_code": "Masukkan kode verifikasi",
- "totp_verify_enable": "Verifikasi dan aktifkan",
- "totp_invalid_code": "Kode verifikasi tidak valid",
- "totp_enabled_success": "Autentikasi dua faktor diaktifkan",
- "totp_disabled_success": "Autentikasi dua faktor dinonaktifkan",
- "totp_setup_failed": "Tidak dapat memulai pengaturan 2FA",
- "totp_required": "Autentikasi dua faktor diperlukan",
- "totp_wrong_code": "Kode 2FA tidak valid"
- },
- "it": {
- "window_title": "Supporto",
- "show": "Show",
- "hide": "Hide",
- "regenerate": "Genera nuovo",
- "set_custom": "Imposta password personalizzata",
- "custom_password": "Password personalizzata",
- "access_mode": "Modalità di accesso",
- "mode_supervised": "Chiedi ogni volta",
- "mode_unattended": "Accesso automatico",
- "mode_disabled": "Disabilitato",
- "help_message": "Descrivi il tuo problema",
- "help_sent": "Richiesta di aiuto inviata",
- "help_failed": "Impossibile inviare la richiesta di aiuto",
- "test_connection": "Test connection",
- "test_running": "Caricamento...",
- "test_ok": "Successo",
- "test_failed": "Errore",
- "test_gateway": "Gateway remoto",
- "test_api": "API di gestione",
- "test_enrollment": "API di registrazione dispositivo",
- "password_too_short": "Password must be at least 6 characters",
- "enrollment_error": "Registrazione non riuscita",
- "consent_title": "Richiesta di accesso remoto",
- "consent_prompt": "Consentire connessione da",
- "session_active": "Sessione attiva",
- "session_with": "Sessione con",
- "session_disconnect": "Nascondi barra sessione",
- "quit": "Esci",
- "totp_title": "Autenticazione a due fattori",
- "totp_enabled": "2FA abilitata su questo dispositivo",
- "totp_disabled": "2FA non abilitata",
- "totp_setup": "Configura 2FA",
- "totp_disable": "Disabilita 2FA",
- "totp_manual_key": "Chiave manuale",
- "totp_step2": "Scansiona l'URI nell'app di autenticazione, poi inserisci il codice",
- "totp_enter_code": "Codice di verifica",
- "totp_verify_enable": "Verifica e abilita",
- "totp_invalid_code": "Codice di verifica non valido",
- "totp_enabled_success": "Autenticazione a due fattori abilitata",
- "totp_disabled_success": "Autenticazione a due fattori disabilitata",
- "totp_setup_failed": "Impossibile avviare la configurazione 2FA",
- "totp_required": "Autenticazione a due fattori richiesta",
- "totp_wrong_code": "Codice 2FA non valido"
- },
- "ja": {
- "window_title": "サポート",
- "show": "Show",
- "hide": "Hide",
- "regenerate": "新規生成",
- "set_custom": "カスタムパスワードを設定",
- "custom_password": "カスタムパスワード",
- "access_mode": "アクセスモード",
- "mode_supervised": "毎回確認",
- "mode_unattended": "無人アクセス",
- "mode_disabled": "無効",
- "help_message": "問題を説明してください",
- "help_sent": "ヘルプリクエストを送信しました",
- "help_failed": "ヘルプリクエストを送信できませんでした",
- "test_connection": "接続テスト",
- "test_running": "接続をテスト中…",
- "test_ok": "接続OK",
- "test_failed": "接続の問題",
- "test_gateway": "リモートゲートウェイ",
- "test_api": "管理API",
- "test_enrollment": "デバイス登録API",
- "password_too_short": "Password must be at least 6 characters",
- "enrollment_error": "登録に失敗しました",
- "consent_title": "リモートアクセス要求",
- "consent_prompt": "接続を許可:",
- "session_active": "アクティブなセッション",
- "session_with": "セッション:",
- "session_disconnect": "セッションバーを非表示",
- "quit": "終了",
- "totp_title": "二要素認証",
- "totp_enabled": "このデバイスで2FAが有効です",
- "totp_disabled": "2FAは無効です",
- "totp_setup": "2FAを設定",
- "totp_disable": "2FAを無効化",
- "totp_manual_key": "手動キー",
- "totp_step2": "認証アプリでURIをスキャンし、コードを入力してください",
- "totp_enter_code": "Verification Code",
- "totp_verify_enable": "確認して有効化",
- "totp_invalid_code": "無効な確認コード",
- "totp_enabled_success": "二要素認証を有効にしました",
- "totp_disabled_success": "二要素認証を無効にしました",
- "totp_setup_failed": "2FAの設定を開始できませんでした",
- "totp_required": "二要素認証が必要です",
- "totp_wrong_code": "無効な2FAコード"
- },
- "ko": {
- "window_title": "지원",
- "show": "표시",
- "hide": "숨기기",
- "regenerate": "새로 생성",
- "set_custom": "사용자 지정 비밀번호 설정",
- "custom_password": "사용자 지정 비밀번호",
- "access_mode": "액세스 모드",
- "mode_supervised": "매번 확인",
- "mode_unattended": "무인 액세스",
- "mode_disabled": "비활성화",
- "help_message": "문제를 설명하세요",
- "help_sent": "도움 요청이 전송되었습니다",
- "help_failed": "도움 요청을 보낼 수 없습니다",
- "test_connection": "연결 테스트",
- "test_running": "연결 테스트 중…",
- "test_ok": "연결 정상",
- "test_failed": "연결 문제",
- "test_gateway": "원격 게이트웨이",
- "test_api": "관리 API",
- "test_enrollment": "장치 등록 API",
- "password_too_short": "비밀번호는 6자 이상이어야 합니다",
- "enrollment_error": "등록 실패",
- "consent_title": "원격 액세스 요청",
- "consent_prompt": "다음에서 연결 허용",
- "session_active": "활성 세션",
- "session_with": "세션:",
- "session_disconnect": "세션 표시줄 숨기기",
- "quit": "종료",
- "totp_title": "2단계 인증",
- "totp_enabled": "이 장치에서 2FA가 활성화됨",
- "totp_disabled": "2FA가 비활성화됨",
- "totp_setup": "2FA 설정",
- "totp_disable": "2FA 비활성화",
- "totp_manual_key": "수동 키",
- "totp_step2": "인증 앱에서 URI를 스캔한 후 코드를 입력하세요",
- "totp_enter_code": "인증 코드 입력",
- "totp_verify_enable": "확인 후 활성화",
- "totp_invalid_code": "잘못된 인증 코드",
- "totp_enabled_success": "2단계 인증이 활성화되었습니다",
- "totp_disabled_success": "2단계 인증이 비활성화되었습니다",
- "totp_setup_failed": "2FA 설정을 시작할 수 없습니다",
- "totp_required": "2단계 인증 필요",
- "totp_wrong_code": "잘못된 2FA 코드"
- },
- "nb": {
- "window_title": "Support",
- "show": "Vis",
- "hide": "Skjul",
- "regenerate": "Generer ny",
- "set_custom": "Angi egendefinert passord",
- "custom_password": "Egendefinert passord",
- "access_mode": "Tilgangsmodus",
- "mode_supervised": "Spør hver gang",
- "mode_unattended": "Uovervåket tilgang",
- "mode_disabled": "Deaktivert",
- "help_message": "Beskriv problemet ditt",
- "help_sent": "Hjelpeforespørsel sendt",
- "help_failed": "Kunne ikke sende hjelpeforespørsel",
- "test_connection": "Test tilkobling",
- "test_running": "Tester tilkobling…",
- "test_ok": "Tilkobling OK",
- "test_failed": "Tilkoblingsproblem",
- "test_gateway": "Remote-gateway",
- "test_api": "Administrasjons-API",
- "test_enrollment": "Enhetsregistrerings-API",
- "password_too_short": "Passordet må være minst 6 tegn",
- "enrollment_error": "Registrering mislyktes",
- "consent_title": "Forespørsel om fjernadgang",
- "consent_prompt": "Tillat tilkobling fra",
- "session_active": "Aktiv økt",
- "session_with": "Økt med",
- "session_disconnect": "Skjul øktlinje",
- "quit": "Avslutt",
- "totp_title": "Tofaktorautentisering",
- "totp_enabled": "2FA er aktivert på denne enheten",
- "totp_disabled": "2FA er ikke aktivert",
- "totp_setup": "Konfigurer 2FA",
- "totp_disable": "Deaktiver 2FA",
- "totp_manual_key": "Manuell nøkkel",
- "totp_step2": "Skann URI i autentiseringsappen og skriv inn koden",
- "totp_enter_code": "Skriv inn bekreftelseskode",
- "totp_verify_enable": "Bekreft og aktiver",
- "totp_invalid_code": "Ugyldig bekreftelseskode",
- "totp_enabled_success": "Tofaktorautentisering aktivert",
- "totp_disabled_success": "Tofaktorautentisering deaktivert",
- "totp_setup_failed": "Kunne ikke starte 2FA-oppsett",
- "totp_required": "Tofaktorautentisering kreves",
- "totp_wrong_code": "Ugyldig 2FA-kode"
- },
- "nl": {
- "window_title": "Ondersteuning",
- "show": "Tonen",
- "hide": "Verbergen",
- "regenerate": "Nieuw genereren",
- "set_custom": "Aangepast wachtwoord instellen",
- "custom_password": "Aangepast wachtwoord",
- "access_mode": "Toegangsmodus",
- "mode_supervised": "Elke keer vragen",
- "mode_unattended": "Onbeheerde toegang",
- "mode_disabled": "Uitgeschakeld",
- "help_message": "Beschrijf uw probleem",
- "help_sent": "Hulpverzoek verzonden",
- "help_failed": "Kon hulpverzoek niet verzenden",
- "test_connection": "Verbinding testen",
- "test_running": "Verbinding testen…",
- "test_ok": "Verbinding OK",
- "test_failed": "Verbindingsprobleem",
- "test_gateway": "Remote-gateway",
- "test_api": "Beheer-API",
- "test_enrollment": "Apparaatregistratie-API",
- "password_too_short": "Wachtwoord moet minimaal 6 tekens bevatten",
- "enrollment_error": "Registratie mislukt",
- "consent_title": "Verzoek om externe toegang",
- "consent_prompt": "Verbinding toestaan van",
- "session_active": "Actieve sessie",
- "session_with": "Sessie met",
- "session_disconnect": "Sessiebalk verbergen",
- "quit": "Afsluiten",
- "totp_title": "Tweefactorauthenticatie",
- "totp_enabled": "2FA is ingeschakeld op dit apparaat",
- "totp_disabled": "2FA is niet ingeschakeld",
- "totp_setup": "2FA instellen",
- "totp_disable": "2FA uitschakelen",
- "totp_manual_key": "Handmatige sleutel",
- "totp_step2": "Scan de URI in uw authenticator-app en voer de code in",
- "totp_enter_code": "Voer verificatiecode in",
- "totp_verify_enable": "Verifiëren en inschakelen",
- "totp_invalid_code": "Ongeldige verificatiecode",
- "totp_enabled_success": "Tweefactorauthenticatie ingeschakeld",
- "totp_disabled_success": "Tweefactorauthenticatie uitgeschakeld",
- "totp_setup_failed": "Kon 2FA-instelling niet starten",
- "totp_required": "Tweefactorauthenticatie vereist",
- "totp_wrong_code": "Ongeldige 2FA-code"
- },
- "pt": {
- "window_title": "Suporte",
- "show": "Mostrar",
- "hide": "Ocultar",
- "regenerate": "Gerar novo",
- "set_custom": "Definir palavra-passe personalizada",
- "custom_password": "Palavra-passe personalizada",
- "access_mode": "Modo de acesso",
- "mode_supervised": "Perguntar sempre",
- "mode_unattended": "Acesso autónomo",
- "mode_disabled": "Desativado",
- "help_message": "Descreva o seu problema",
- "help_sent": "Pedido de ajuda enviado",
- "help_failed": "Não foi possível enviar o pedido de ajuda",
- "test_connection": "Testar ligação",
- "test_running": "A testar ligação…",
- "test_ok": "Ligação OK",
- "test_failed": "Problema de ligação",
- "test_gateway": "Gateway remoto",
- "test_api": "API de gestão",
- "test_enrollment": "API de registo do dispositivo",
- "password_too_short": "A palavra-passe deve ter pelo menos 6 caracteres",
- "enrollment_error": "Registo falhou",
- "consent_title": "Pedido de acesso remoto",
- "consent_prompt": "Permitir ligação de",
- "session_active": "Sessão ativa",
- "session_with": "Sessão com",
- "session_disconnect": "Ocultar barra de sessão",
- "quit": "Sair",
- "totp_title": "Autenticação de dois fatores",
- "totp_enabled": "2FA ativada neste dispositivo",
- "totp_disabled": "2FA não ativada",
- "totp_setup": "Configurar 2FA",
- "totp_disable": "Desativar 2FA",
- "totp_manual_key": "Chave manual",
- "totp_step2": "Digitalize o URI na aplicação autenticadora e introduza o código",
- "totp_enter_code": "Introduza o código de verificação",
- "totp_verify_enable": "Verificar e ativar",
- "totp_invalid_code": "Código de verificação inválido",
- "totp_enabled_success": "Autenticação de dois fatores ativada",
- "totp_disabled_success": "Autenticação de dois fatores desativada",
- "totp_setup_failed": "Não foi possível iniciar a configuração 2FA",
- "totp_required": "Autenticação de dois fatores necessária",
- "totp_wrong_code": "Código 2FA inválido"
- },
- "ro": {
- "window_title": "Asistență",
- "show": "Afișează",
- "hide": "Ascunde",
- "regenerate": "Generează nou",
- "set_custom": "Setează parolă personalizată",
- "custom_password": "Parolă personalizată",
- "access_mode": "Mod de acces",
- "mode_supervised": "Întreabă de fiecare dată",
- "mode_unattended": "Acces nesupravegheat",
- "mode_disabled": "Dezactivat",
- "help_message": "Descrieți problema",
- "help_sent": "Cerere de ajutor trimisă",
- "help_failed": "Nu s-a putut trimite cererea de ajutor",
- "test_connection": "Testează conexiunea",
- "test_running": "Se testează conexiunea…",
- "test_ok": "Conexiune OK",
- "test_failed": "Problemă de conexiune",
- "test_gateway": "Gateway remote",
- "test_api": "API de administrare",
- "test_enrollment": "API înregistrare dispozitiv",
- "password_too_short": "Parola trebuie să aibă cel puțin 6 caractere",
- "enrollment_error": "Înregistrare eșuată",
- "consent_title": "Cerere de acces la distanță",
- "consent_prompt": "Permite conexiunea de la",
- "session_active": "Sesiune activă",
- "session_with": "Sesiune cu",
- "session_disconnect": "Ascunde bara de sesiune",
- "quit": "Ieșire",
- "totp_title": "Autentificare cu doi factori",
- "totp_enabled": "2FA este activată pe acest dispozitiv",
- "totp_disabled": "2FA nu este activată",
- "totp_setup": "Configurează 2FA",
- "totp_disable": "Dezactivează 2FA",
- "totp_manual_key": "Cheie manuală",
- "totp_step2": "Scanați URI-ul în aplicația de autentificare, apoi introduceți codul",
- "totp_enter_code": "Introduceți codul de verificare",
- "totp_verify_enable": "Verifică și activează",
- "totp_invalid_code": "Cod de verificare invalid",
- "totp_enabled_success": "Autentificare cu doi factori activată",
- "totp_disabled_success": "Autentificare cu doi factori dezactivată",
- "totp_setup_failed": "Nu s-a putut porni configurarea 2FA",
- "totp_required": "Autentificare cu doi factori necesară",
- "totp_wrong_code": "Cod 2FA invalid"
- },
- "sv": {
- "window_title": "Support",
- "show": "Visa",
- "hide": "Dölj",
- "regenerate": "Generera ny",
- "set_custom": "Ange anpassat lösenord",
- "custom_password": "Anpassat lösenord",
- "access_mode": "Åtkomstläge",
- "mode_supervised": "Fråga varje gång",
- "mode_unattended": "Oövervakad åtkomst",
- "mode_disabled": "Inaktiverad",
- "help_message": "Beskriv ditt problem",
- "help_sent": "Hjälpförfrågan skickad",
- "help_failed": "Kunde inte skicka hjälpförfrågan",
- "test_connection": "Testa anslutning",
- "test_running": "Testar anslutning…",
- "test_ok": "Anslutning OK",
- "test_failed": "Anslutningsproblem",
- "test_gateway": "Remote-gateway",
- "test_api": "Hanterings-API",
- "test_enrollment": "Enhetsregistrerings-API",
- "password_too_short": "Lösenordet måste vara minst 6 tecken",
- "enrollment_error": "Registrering misslyckades",
- "consent_title": "Begäran om fjärråtkomst",
- "consent_prompt": "Tillåt anslutning från",
- "session_active": "Aktiv session",
- "session_with": "Session med",
- "session_disconnect": "Dölj sessionsfält",
- "quit": "Avsluta",
- "totp_title": "Tvåfaktorsautentisering",
- "totp_enabled": "2FA är aktiverat på den här enheten",
- "totp_disabled": "2FA är inte aktiverat",
- "totp_setup": "Konfigurera 2FA",
- "totp_disable": "Inaktivera 2FA",
- "totp_manual_key": "Manuell nyckel",
- "totp_step2": "Skanna URI i din autentiseringsapp och ange koden",
- "totp_enter_code": "Ange verifieringskod",
- "totp_verify_enable": "Verifiera och aktivera",
- "totp_invalid_code": "Ogiltig verifieringskod",
- "totp_enabled_success": "Tvåfaktorsautentisering aktiverad",
- "totp_disabled_success": "Tvåfaktorsautentisering inaktiverad",
- "totp_setup_failed": "Kunde inte starta 2FA-konfiguration",
- "totp_required": "Tvåfaktorsautentisering krävs",
- "totp_wrong_code": "Ogiltig 2FA-kod"
- },
- "th": {
- "window_title": "การสนับสนุน",
- "show": "แสดง",
- "hide": "ซ่อน",
- "regenerate": "สร้างใหม่",
- "set_custom": "ตั้งรหัสผ่านเอง",
- "custom_password": "รหัสผ่านเอง",
- "access_mode": "โหมดการเข้าถึง",
- "mode_supervised": "ถามทุกครั้ง",
- "mode_unattended": "เข้าถึงโดยไม่มีการดูแล",
- "mode_disabled": "ปิดใช้งาน",
- "help_message": "อธิบายปัญหาของคุณ",
- "help_sent": "ส่งคำขอความช่วยเหลือแล้ว",
- "help_failed": "ไม่สามารถส่งคำขอความช่วยเหลือ",
- "test_connection": "ทดสอบการเชื่อมต่อ",
- "test_running": "กำลังทดสอบการเชื่อมต่อ…",
- "test_ok": "การเชื่อมต่อปกติ",
- "test_failed": "ปัญหาการเชื่อมต่อ",
- "test_gateway": "เกตเวย์ระยะไกล",
- "test_api": "API การจัดการ",
- "test_enrollment": "API ลงทะเบียนอุปกรณ์",
- "password_too_short": "รหัสผ่านต้องมีอย่างน้อย 6 ตัวอักษร",
- "enrollment_error": "การลงทะเบียนล้มเหลว",
- "consent_title": "คำขอเข้าถึงระยะไกล",
- "consent_prompt": "อนุญาตการเชื่อมต่อจาก",
- "session_active": "เซสชันที่ใช้งานอยู่",
- "session_with": "เซสชันกับ",
- "session_disconnect": "ซ่อนแถบเซสชัน",
- "quit": "ออก",
- "totp_title": "การยืนยันตัวตนสองขั้นตอน",
- "totp_enabled": "เปิดใช้ 2FA บนอุปกรณ์นี้",
- "totp_disabled": "ไม่ได้เปิดใช้ 2FA",
- "totp_setup": "ตั้งค่า 2FA",
- "totp_disable": "ปิดใช้ 2FA",
- "totp_manual_key": "คีย์ด้วยตนเอง",
- "totp_step2": "สแกน URI ในแอปยืนยันตัวตน แล้วป้อนรหัส",
- "totp_enter_code": "ป้อนรหัสยืนยัน",
- "totp_verify_enable": "ยืนยันและเปิดใช้",
- "totp_invalid_code": "รหัสยืนยันไม่ถูกต้อง",
- "totp_enabled_success": "เปิดใช้การยืนยันตัวตนสองขั้นตอน",
- "totp_disabled_success": "ปิดใช้การยืนยันตัวตนสองขั้นตอน",
- "totp_setup_failed": "ไม่สามารถเริ่มตั้งค่า 2FA",
- "totp_required": "ต้องใช้การยืนยันตัวตนสองขั้นตอน",
- "totp_wrong_code": "รหัส 2FA ไม่ถูกต้อง"
- },
- "tr": {
- "window_title": "Destek",
- "show": "Göster",
- "hide": "Gizle",
- "regenerate": "Yeni oluştur",
- "set_custom": "Özel parola ayarla",
- "custom_password": "Özel parola",
- "access_mode": "Erişim modu",
- "mode_supervised": "Her seferinde sor",
- "mode_unattended": "Denetimsiz erişim",
- "mode_disabled": "Devre dışı",
- "help_message": "Sorununuzu açıklayın",
- "help_sent": "Yardım isteği gönderildi",
- "help_failed": "Yardım isteği gönderilemedi",
- "test_connection": "Bağlantıyı test et",
- "test_running": "Bağlantı test ediliyor…",
- "test_ok": "Bağlantı OK",
- "test_failed": "Bağlantı sorunu",
- "test_gateway": "Uzak ağ geçidi",
- "test_api": "Yönetim API",
- "test_enrollment": "Cihaz kayıt API",
- "password_too_short": "Parola en az 6 karakter olmalıdır",
- "enrollment_error": "Kayıt başarısız",
- "consent_title": "Uzak erişim isteği",
- "consent_prompt": "Bağlantıya izin ver:",
- "session_active": "Etkin oturum",
- "session_with": "Oturum:",
- "session_disconnect": "Oturum çubuğunu gizle",
- "quit": "Çık",
- "totp_title": "İki faktörlü kimlik doğrulama",
- "totp_enabled": "Bu cihazda 2FA etkin",
- "totp_disabled": "2FA etkin değil",
- "totp_setup": "2FA kur",
- "totp_disable": "2FA devre dışı bırak",
- "totp_manual_key": "Manuel anahtar",
- "totp_step2": "Kimlik doğrulayıcı uygulamasında URI tarayın ve kodu girin",
- "totp_enter_code": "Doğrulama kodunu girin",
- "totp_verify_enable": "Doğrula ve etkinleştir",
- "totp_invalid_code": "Geçersiz doğrulama kodu",
- "totp_enabled_success": "İki faktörlü kimlik doğrulama etkinleştirildi",
- "totp_disabled_success": "İki faktörlü kimlik doğrulama devre dışı",
- "totp_setup_failed": "2FA kurulumu başlatılamadı",
- "totp_required": "İki faktörlü kimlik doğrulama gerekli",
- "totp_wrong_code": "Geçersiz 2FA kodu"
- },
- "uk": {
- "window_title": "Підтримка",
- "show": "Показати",
- "hide": "Приховати",
- "regenerate": "Згенерувати новий",
- "set_custom": "Встановити власний пароль",
- "custom_password": "Власний пароль",
- "access_mode": "Режим доступу",
- "mode_supervised": "Запитувати щоразу",
- "mode_unattended": "Доступ без нагляду",
- "mode_disabled": "Вимкнено",
- "help_message": "Опишіть вашу проблему",
- "help_sent": "Запит допомоги надіслано",
- "help_failed": "Не вдалося надіслати запит допомоги",
- "test_connection": "Перевірити з’єднання",
- "test_running": "Перевірка з’єднання…",
- "test_ok": "З’єднання OK",
- "test_failed": "Проблема з’єднання",
- "test_gateway": "Віддалений шлюз",
- "test_api": "API керування",
- "test_enrollment": "API реєстрації пристрою",
- "password_too_short": "Пароль має містити щонайменше 6 символів",
- "enrollment_error": "Реєстрація не вдалася",
- "consent_title": "Запит віддаленого доступу",
- "consent_prompt": "Дозволити підключення від",
- "session_active": "Активна сесія",
- "session_with": "Сесія з",
- "session_disconnect": "Приховати панель сесії",
- "quit": "Вийти",
- "totp_title": "Двофакторна автентифікація",
- "totp_enabled": "2FA увімкнено на цьому пристрої",
- "totp_disabled": "2FA не увімкнено",
- "totp_setup": "Налаштувати 2FA",
- "totp_disable": "Вимкнути 2FA",
- "totp_manual_key": "Ручний ключ",
- "totp_step2": "Відскануйте URI в додатку автентифікації та введіть код",
- "totp_enter_code": "Введіть код підтвердження",
- "totp_verify_enable": "Підтвердити та увімкнути",
- "totp_invalid_code": "Невірний код підтвердження",
- "totp_enabled_success": "Двофакторну автентифікацію увімкнено",
- "totp_disabled_success": "Двофакторну автентифікацію вимкнено",
- "totp_setup_failed": "Не вдалося розпочати налаштування 2FA",
- "totp_required": "Потрібна двофакторна автентифікація",
- "totp_wrong_code": "Невірний код 2FA"
- },
- "vi": {
- "window_title": "Hỗ trợ",
- "show": "Hiện",
- "hide": "Ẩn",
- "regenerate": "Tạo mới",
- "set_custom": "Đặt mật khẩu tùy chỉnh",
- "custom_password": "Mật khẩu tùy chỉnh",
- "access_mode": "Chế độ truy cập",
- "mode_supervised": "Hỏi mỗi lần",
- "mode_unattended": "Truy cập không giám sát",
- "mode_disabled": "Đã tắt",
- "help_message": "Mô tả vấn đề của bạn",
- "help_sent": "Đã gửi yêu cầu trợ giúp",
- "help_failed": "Không thể gửi yêu cầu trợ giúp",
- "test_connection": "Kiểm tra kết nối",
- "test_running": "Đang kiểm tra kết nối…",
- "test_ok": "Kết nối OK",
- "test_failed": "Sự cố kết nối",
- "test_gateway": "Cổng từ xa",
- "test_api": "API quản trị",
- "test_enrollment": "API đăng ký thiết bị",
- "password_too_short": "Mật khẩu phải có ít nhất 6 ký tự",
- "enrollment_error": "Đăng ký thất bại",
- "consent_title": "Yêu cầu truy cập từ xa",
- "consent_prompt": "Cho phép kết nối từ",
- "session_active": "Phiên đang hoạt động",
- "session_with": "Phiên với",
- "session_disconnect": "Ẩn thanh phiên",
- "quit": "Thoát",
- "totp_title": "Xác thực hai yếu tố",
- "totp_enabled": "2FA đã bật trên thiết bị này",
- "totp_disabled": "2FA chưa bật",
- "totp_setup": "Thiết lập 2FA",
- "totp_disable": "Tắt 2FA",
- "totp_manual_key": "Khóa thủ công",
- "totp_step2": "Quét URI trong ứng dụng xác thực, sau đó nhập mã",
- "totp_enter_code": "Nhập mã xác minh",
- "totp_verify_enable": "Xác minh và bật",
- "totp_invalid_code": "Mã xác minh không hợp lệ",
- "totp_enabled_success": "Đã bật xác thực hai yếu tố",
- "totp_disabled_success": "Đã tắt xác thực hai yếu tố",
- "totp_setup_failed": "Không thể bắt đầu thiết lập 2FA",
- "totp_required": "Yêu cầu xác thực hai yếu tố",
- "totp_wrong_code": "Mã 2FA không hợp lệ"
- },
- "zh": {
- "window_title": "支持",
- "show": "Show",
- "hide": "Hide",
- "regenerate": "重新生成",
- "set_custom": "设置自定义密码",
- "custom_password": "自定义密码",
- "access_mode": "访问模式",
- "mode_supervised": "每次询问",
- "mode_unattended": "无人值守访问",
- "mode_disabled": "已禁用",
- "help_message": "描述您的问题",
- "help_sent": "已发送帮助请求",
- "help_failed": "无法发送帮助请求",
- "test_connection": "测试连接",
- "test_running": "正在测试连接…",
- "test_ok": "连接正常",
- "test_failed": "连接问题",
- "test_gateway": "远程网关",
- "test_api": "管理 API",
- "test_enrollment": "设备注册 API",
- "password_too_short": "Password must be at least 6 characters",
- "enrollment_error": "注册失败",
- "consent_title": "远程访问请求",
- "consent_prompt": "允许来自以下对象的连接",
- "session_active": "活动会话",
- "session_with": "会话对象",
- "session_disconnect": "隐藏会话栏",
- "quit": "退出(仅管理员)",
- "totp_title": "双因素认证",
- "totp_enabled": "此设备已启用 2FA",
- "totp_disabled": "未启用 2FA",
- "totp_setup": "设置 2FA",
- "totp_disable": "禁用 2FA",
- "totp_manual_key": "手动密钥",
- "totp_step2": "在验证器应用中扫描 URI,然后输入验证码",
- "totp_enter_code": "验证码",
- "totp_verify_enable": "验证并启用",
- "totp_invalid_code": "验证码无效",
- "totp_enabled_success": "已启用双因素认证",
- "totp_disabled_success": "已禁用双因素认证",
- "totp_setup_failed": "无法开始 2FA 设置",
- "totp_required": "需要双因素认证",
- "totp_wrong_code": "2FA 验证码无效"
- },
- "zh-TW": {
- "window_title": "支援",
- "show": "Show",
- "hide": "Hide",
- "regenerate": "重新產生",
- "set_custom": "設定自訂密碼",
- "custom_password": "自訂密碼",
- "access_mode": "存取模式",
- "mode_supervised": "每次詢問",
- "mode_unattended": "無人值守存取",
- "mode_disabled": "已停用",
- "help_message": "描述您的問題",
- "help_sent": "已傳送協助請求",
- "help_failed": "無法傳送協助請求",
- "test_connection": "測試連線",
- "test_running": "正在測試連線…",
- "test_ok": "連線正常",
- "test_failed": "連線問題",
- "test_gateway": "遠端閘道",
- "test_api": "管理 API",
- "test_enrollment": "裝置註冊 API",
- "password_too_short": "Password must be at least 6 characters",
- "enrollment_error": "註冊失敗",
- "consent_title": "遠端存取請求",
- "consent_prompt": "允許來自以下對象的連線",
- "session_active": "使用中工作階段",
- "session_with": "工作階段對象",
- "session_disconnect": "隱藏工作階段列",
- "quit": "結束",
- "totp_title": "雙因素驗證",
- "totp_enabled": "此裝置已啟用 2FA",
- "totp_disabled": "未啟用 2FA",
- "totp_setup": "設定 2FA",
- "totp_disable": "停用 2FA",
- "totp_manual_key": "手動金鑰",
- "totp_step2": "在驗證器應用程式中掃描 URI,然後輸入驗證碼",
- "totp_enter_code": "驗證碼",
- "totp_verify_enable": "驗證並啟用",
- "totp_invalid_code": "驗證碼無效",
- "totp_enabled_success": "已啟用雙因素驗證",
- "totp_disabled_success": "已停用雙因素驗證",
- "totp_setup_failed": "無法開始 2FA 設定",
- "totp_required": "需要雙因素驗證",
- "totp_wrong_code": "2FA 驗證碼無效"
- }
-}
diff --git a/betterdesk-support-agent/locales/sv.json b/betterdesk-support-agent/locales/sv.json
deleted file mode 100644
index 08745d47..00000000
--- a/betterdesk-support-agent/locales/sv.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Stöd",
- "your_id": "Ditt ID",
- "access_password": "Lösenord",
- "show": "Visa",
- "hide": "Dölj",
- "copy": "Kopiera",
- "copied": "Kopierade till urklipp",
- "regenerate": "Generera ny",
- "password_regenerated": "Nytt lösenord genererat",
- "set_custom": "Ange anpassat lösenord",
- "custom_password": "Anpassat lösenord",
- "access_mode": "Åtkomstläge",
- "mode_supervised": "Fråga varje gång",
- "mode_unattended": "Oövervakad åtkomst",
- "mode_disabled": "Inaktiverad",
- "receive_support": "Få support",
- "receive_support_hint": "Begär hjälp eller chatta med support. Du kan också dela ditt ID och lösenord nedan.",
- "or_share_id": "Eller dela ditt ID",
- "share_id_password": "Dela ID och lösenord",
- "share_id_hint": "Dela dessa uppgifter så att support kan ansluta till din enhet.",
- "ongoing_session": "Pågående session",
- "close_session": "Stäng session",
- "request_help": "Begär hjälp",
- "help_message": "Beskriv ditt problem",
- "send": "Skicka",
- "cancel": "Avbryt",
- "help_sent": "Hjälpförfrågan skickad",
- "help_failed": "Kunde inte skicka hjälpförfrågan",
- "connected": "Ansluten",
- "disconnected": "Ansluter...",
- "save": "Spara",
- "settings": "Inställningar",
- "test_connection": "Testa anslutning",
- "test_running": "Testar anslutning…",
- "test_ok": "Anslutning OK",
- "test_failed": "Anslutningsproblem",
- "test_gateway": "Remote-gateway",
- "test_api": "Hanterings-API",
- "test_enrollment": "Enhetsregistrerings-API",
- "close": "Stäng",
- "password_too_short": "Lösenordet måste vara minst 6 tecken",
- "status_ready": "Redo att ansluta (säker anslutning)",
- "enrollment_pending": "Väntande",
- "enrollment_rejected": "Avvisad",
- "enrollment_error": "Registrering misslyckades",
- "consent_title": "Expertinformation",
- "consent_prompt": "Tillåt anslutning från",
- "consent_ack": "Jag har läst expertinformationen.",
- "consent_display_name": "Visningsnamn",
- "consent_session": "Session",
- "session_active": "Aktiv session",
- "session_with": "Session med",
- "session_disconnect": "Dölj sessionsfält",
- "disconnect": "Koppla från",
- "chat_with_support": "Chatta med supporten",
- "settings_language": "Språk",
- "quit": "Avsluta",
- "consent_accept": "Fortsätt",
- "consent_deny": "Avbryt session",
- "chat_title": "Chatt",
- "chat_send": "Skicka",
- "chat_placeholder": "Skriv ett meddelande...",
- "chat_empty": "Inga meddelanden än",
- "totp_title": "Tvåfaktorsautentisering",
- "totp_enabled": "2FA är aktiverat på den här enheten",
- "totp_disabled": "2FA är inte aktiverat",
- "totp_setup": "Konfigurera 2FA",
- "totp_disable": "Inaktivera 2FA",
- "totp_manual_key": "Manuell nyckel",
- "totp_step2": "Skanna URI i din autentiseringsapp och ange koden",
- "totp_enter_code": "Ange verifieringskod",
- "totp_verify_enable": "Verifiera och aktivera",
- "totp_invalid_code": "Ogiltig verifieringskod",
- "totp_enabled_success": "Tvåfaktorsautentisering aktiverad",
- "totp_disabled_success": "Tvåfaktorsautentisering inaktiverad",
- "totp_setup_failed": "Kunde inte starta 2FA-konfiguration",
- "totp_required": "Tvåfaktorsautentisering krävs",
- "totp_wrong_code": "Ogiltig 2FA-kod"
-}
diff --git a/betterdesk-support-agent/locales/th.json b/betterdesk-support-agent/locales/th.json
deleted file mode 100644
index 1d48776b..00000000
--- a/betterdesk-support-agent/locales/th.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "การสนับสนุน",
- "your_id": "บัตรประจำตัวของคุณ",
- "access_password": "รหัสผ่าน",
- "show": "แสดง",
- "hide": "ซ่อน",
- "copy": "คัดลอก",
- "copied": "คัดลอกไปยังคลิปบอร์ดแล้ว",
- "regenerate": "สร้างใหม่",
- "password_regenerated": "สร้างรหัสผ่านใหม่แล้ว",
- "set_custom": "ตั้งรหัสผ่านเอง",
- "custom_password": "รหัสผ่านเอง",
- "access_mode": "โหมดการเข้าถึง",
- "mode_supervised": "ถามทุกครั้ง",
- "mode_unattended": "เข้าถึงโดยไม่มีการดูแล",
- "mode_disabled": "ปิดใช้งาน",
- "receive_support": "รับการสนับสนุน",
- "receive_support_hint": "ขอความช่วยเหลือหรือแชทกับฝ่ายสนับสนุน และสามารถแชร์ ID กับรหัสผ่านด้านล่างได้",
- "or_share_id": "หรือแชร์ ID ของคุณ",
- "share_id_password": "แชร์ ID และรหัสผ่าน",
- "share_id_hint": "แชร์ข้อมูลเหล่านี้เพื่อให้ฝ่ายสนับสนุนเชื่อมต่อกับอุปกรณ์ของคุณได้",
- "ongoing_session": "เซสชันที่กำลังดำเนินการ",
- "close_session": "ปิดเซสชัน",
- "request_help": "ขอความช่วยเหลือ",
- "help_message": "อธิบายปัญหาของคุณ",
- "send": "ส่ง",
- "cancel": "ยกเลิก",
- "help_sent": "ส่งคำขอความช่วยเหลือแล้ว",
- "help_failed": "ไม่สามารถส่งคำขอความช่วยเหลือ",
- "connected": "เชื่อมต่อแล้ว",
- "disconnected": "กำลังเชื่อมต่อ...",
- "save": "บันทึก",
- "settings": "การตั้งค่า",
- "test_connection": "ทดสอบการเชื่อมต่อ",
- "test_running": "กำลังทดสอบการเชื่อมต่อ…",
- "test_ok": "การเชื่อมต่อปกติ",
- "test_failed": "ปัญหาการเชื่อมต่อ",
- "test_gateway": "เกตเวย์ระยะไกล",
- "test_api": "API การจัดการ",
- "test_enrollment": "API ลงทะเบียนอุปกรณ์",
- "close": "ปิด",
- "password_too_short": "รหัสผ่านต้องมีอย่างน้อย 6 ตัวอักษร",
- "status_ready": "พร้อมเชื่อมต่อ (การเชื่อมต่อที่ปลอดภัย)",
- "enrollment_pending": "รอดำเนินการ",
- "enrollment_rejected": "ถูกปฏิเสธ",
- "enrollment_error": "การลงทะเบียนล้มเหลว",
- "consent_title": "ข้อมูลผู้เชี่ยวชาญ",
- "consent_prompt": "อนุญาตการเชื่อมต่อจาก",
- "consent_ack": "ฉันได้อ่านข้อมูลผู้เชี่ยวชาญแล้ว",
- "consent_display_name": "ชื่อที่แสดง",
- "consent_session": "เซสชัน",
- "session_active": "เซสชันที่ใช้งานอยู่",
- "session_with": "เซสชันกับ",
- "session_disconnect": "ซ่อนแถบเซสชัน",
- "disconnect": "ตัดการเชื่อมต่อ",
- "chat_with_support": "แชทกับฝ่ายสนับสนุน",
- "settings_language": "ภาษา",
- "quit": "ออก",
- "consent_accept": "ดำเนินการต่อ",
- "consent_deny": "ยกเลิกเซสชัน",
- "chat_title": "แชท",
- "chat_send": "ส่ง",
- "chat_placeholder": "���ิมพ์ข้อความ...",
- "chat_empty": "ยังไม่มีข้อความ",
- "totp_title": "การยืนยันตัวตนสองขั้นตอน",
- "totp_enabled": "เปิดใช้ 2FA บนอุปกรณ์นี้",
- "totp_disabled": "ไม่ได้เปิดใช้ 2FA",
- "totp_setup": "ตั้งค่า 2FA",
- "totp_disable": "ปิดใช้ 2FA",
- "totp_manual_key": "คีย์ด้วยตนเอง",
- "totp_step2": "สแกน URI ในแอปยืนยันตัวตน แล้วป้อนรหัส",
- "totp_enter_code": "ป้อนรหัสยืนยัน",
- "totp_verify_enable": "ยืนยันและเปิดใช้",
- "totp_invalid_code": "รหัสยืนยันไม่ถูกต้อง",
- "totp_enabled_success": "เปิดใช้การยืนยันตัวตนสองขั้นตอน",
- "totp_disabled_success": "ปิดใช้การยืนยันตัวตนสองขั้นตอน",
- "totp_setup_failed": "ไม่สามารถเริ่มตั้งค่า 2FA",
- "totp_required": "ต้องใช้การยืนยันตัวตนสองขั้นตอน",
- "totp_wrong_code": "รหัส 2FA ไม่ถูกต้อง"
-}
diff --git a/betterdesk-support-agent/locales/tr.json b/betterdesk-support-agent/locales/tr.json
deleted file mode 100644
index 45e61192..00000000
--- a/betterdesk-support-agent/locales/tr.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Destek",
- "your_id": "Kimliğiniz",
- "access_password": "Şifre",
- "show": "Göster",
- "hide": "Gizle",
- "copy": "Kopyala",
- "copied": "Panoya kopyalandı",
- "regenerate": "Yeni oluştur",
- "password_regenerated": "Yeni parola oluşturuldu",
- "set_custom": "Özel parola ayarla",
- "custom_password": "Özel parola",
- "access_mode": "Erişim modu",
- "mode_supervised": "Her seferinde sor",
- "mode_unattended": "Denetimsiz erişim",
- "mode_disabled": "Devre dışı",
- "receive_support": "Destek al",
- "receive_support_hint": "Yardım isteyin veya destekle sohbet edin. Aşağıdaki ID ve parolanızı da paylaşabilirsiniz.",
- "or_share_id": "Veya ID’nizi paylaşın",
- "share_id_password": "ID ve parolayı paylaş",
- "share_id_hint": "Destek cihazınıza bağlanabilsin diye bu kimlik bilgilerini paylaşın.",
- "ongoing_session": "Devam eden oturum",
- "close_session": "Oturumu kapat",
- "request_help": "Yardım iste",
- "help_message": "Sorununuzu açıklayın",
- "send": "Gönder",
- "cancel": "İptal",
- "help_sent": "Yardım isteği gönderildi",
- "help_failed": "Yardım isteği gönderilemedi",
- "connected": "Bağlandı",
- "disconnected": "Bağlanıyor...",
- "save": "Kaydet",
- "settings": "Ayarlar",
- "test_connection": "Bağlantıyı test et",
- "test_running": "Bağlantı test ediliyor…",
- "test_ok": "Bağlantı OK",
- "test_failed": "Bağlantı sorunu",
- "test_gateway": "Uzak ağ geçidi",
- "test_api": "Yönetim API",
- "test_enrollment": "Cihaz kayıt API",
- "close": "Kapat",
- "password_too_short": "Parola en az 6 karakter olmalıdır",
- "status_ready": "Bağlanmaya hazır (güvenli bağlantı)",
- "enrollment_pending": "Beklemede",
- "enrollment_rejected": "Reddedildi",
- "enrollment_error": "Kayıt başarısız",
- "consent_title": "Uzman bilgileri",
- "consent_prompt": "Bağlantıya izin ver:",
- "consent_ack": "Uzman bilgilerini okudum.",
- "consent_display_name": "Görünen ad",
- "consent_session": "Oturum",
- "session_active": "Etkin oturum",
- "session_with": "Oturum:",
- "session_disconnect": "Oturum çubuğunu gizle",
- "disconnect": "Bağlantıyı kes",
- "chat_with_support": "Destek ekibiyle sohbet edin",
- "settings_language": "Dil",
- "quit": "Çık",
- "consent_accept": "Devam",
- "consent_deny": "Oturumu iptal et",
- "chat_title": "Sohbet",
- "chat_send": "Gönder",
- "chat_placeholder": "Bir mesaj yazın...",
- "chat_empty": "Henüz mesaj yok",
- "totp_title": "İki faktörlü kimlik doğrulama",
- "totp_enabled": "Bu cihazda 2FA etkin",
- "totp_disabled": "2FA etkin değil",
- "totp_setup": "2FA kur",
- "totp_disable": "2FA devre dışı bırak",
- "totp_manual_key": "Manuel anahtar",
- "totp_step2": "Kimlik doğrulayıcı uygulamasında URI tarayın ve kodu girin",
- "totp_enter_code": "Doğrulama kodunu girin",
- "totp_verify_enable": "Doğrula ve etkinleştir",
- "totp_invalid_code": "Geçersiz doğrulama kodu",
- "totp_enabled_success": "İki faktörlü kimlik doğrulama etkinleştirildi",
- "totp_disabled_success": "İki faktörlü kimlik doğrulama devre dışı",
- "totp_setup_failed": "2FA kurulumu başlatılamadı",
- "totp_required": "İki faktörlü kimlik doğrulama gerekli",
- "totp_wrong_code": "Geçersiz 2FA kodu"
-}
diff --git a/betterdesk-support-agent/locales/uk.json b/betterdesk-support-agent/locales/uk.json
deleted file mode 100644
index f79723cf..00000000
--- a/betterdesk-support-agent/locales/uk.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Підтримка",
- "your_id": "Ваше посвідчення особи",
- "access_password": "Пароль",
- "show": "Показати",
- "hide": "Приховати",
- "copy": "Копіювати",
- "copied": "Скопійовано в буфер обміну",
- "regenerate": "Згенерувати новий",
- "password_regenerated": "Новий пароль згенеровано",
- "set_custom": "Встановити власний пароль",
- "custom_password": "Власний пароль",
- "access_mode": "Режим доступу",
- "mode_supervised": "Запитувати щоразу",
- "mode_unattended": "Доступ без нагляду",
- "mode_disabled": "Вимкнено",
- "receive_support": "Отримати підтримку",
- "receive_support_hint": "Надішліть запит про допомогу або поспілкуйтеся з підтримкою. Також можна поділитися ID і паролем нижче.",
- "or_share_id": "Або поділіться своїм ID",
- "share_id_password": "Поділитися ID і паролем",
- "share_id_hint": "Поділіться цими даними, щоб підтримка могла підключитися до вашого пристрою.",
- "ongoing_session": "Активний сеанс",
- "close_session": "Закрити сеанс",
- "request_help": "Запросити допомогу",
- "help_message": "Опишіть вашу проблему",
- "send": "Надіслати",
- "cancel": "Скасувати",
- "help_sent": "Запит допомоги надіслано",
- "help_failed": "Не вдалося надіслати запит допомоги",
- "connected": "Підключено",
- "disconnected": "Підключення...",
- "save": "зберегти",
- "settings": "Налаштування",
- "test_connection": "Перевірити з’єднання",
- "test_running": "Перевірка з’єднання…",
- "test_ok": "З’єднання OK",
- "test_failed": "Проблема з’єднання",
- "test_gateway": "Віддалений шлюз",
- "test_api": "API керування",
- "test_enrollment": "API реєстрації пристрою",
- "close": "Закрити",
- "password_too_short": "Пароль має містити щонайменше 6 символів",
- "status_ready": "Готово до підключення (захищене з’єднання)",
- "enrollment_pending": "Очікує",
- "enrollment_rejected": "Відхилено",
- "enrollment_error": "Реєстрація не вдалася",
- "consent_title": "Інформація про експерта",
- "consent_prompt": "Дозволити підключення від",
- "consent_ack": "Я прочитав(ла) інформацію про експерта.",
- "consent_display_name": "Відображуване ім’я",
- "consent_session": "Сеанс",
- "session_active": "Активна сесія",
- "session_with": "Сесія з",
- "session_disconnect": "Приховати панель сесії",
- "disconnect": "Від’єднати",
- "chat_with_support": "Спілкуйтеся зі службою підтримки",
- "settings_language": "Мова",
- "quit": "Вийти",
- "consent_accept": "Продовжити",
- "consent_deny": "Скасувати сеанс",
- "chat_title": "Чат",
- "chat_send": "Надіслати",
- "chat_placeholder": "Введіть повідомлення...",
- "chat_empty": "Поки немає повідомлень",
- "totp_title": "Двофакторна автентифікація",
- "totp_enabled": "2FA увімкнено на цьому пристрої",
- "totp_disabled": "2FA не увімкнено",
- "totp_setup": "Налаштувати 2FA",
- "totp_disable": "Вимкнути 2FA",
- "totp_manual_key": "Ручний ключ",
- "totp_step2": "Відскануйте URI в додатку автентифікації та введіть код",
- "totp_enter_code": "Введіть код підтвердження",
- "totp_verify_enable": "Підтвердити та увімкнути",
- "totp_invalid_code": "Невірний код підтвердження",
- "totp_enabled_success": "Двофакторну автентифікацію увімкнено",
- "totp_disabled_success": "Двофакторну автентифікацію вимкнено",
- "totp_setup_failed": "Не вдалося розпочати налаштування 2FA",
- "totp_required": "Потрібна двофакторна автентифікація",
- "totp_wrong_code": "Невірний код 2FA"
-}
diff --git a/betterdesk-support-agent/locales/vi.json b/betterdesk-support-agent/locales/vi.json
deleted file mode 100644
index 0fa3609d..00000000
--- a/betterdesk-support-agent/locales/vi.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "Hỗ trợ",
- "your_id": "ID của bạn",
- "access_password": "Mật khẩu",
- "show": "Hiện",
- "hide": "Ẩn",
- "copy": "Sao chép",
- "copied": "Đã sao chép vào bảng nhớ tạm",
- "regenerate": "Tạo mới",
- "password_regenerated": "Đã tạo mật khẩu mới",
- "set_custom": "Đặt mật khẩu tùy chỉnh",
- "custom_password": "Mật khẩu tùy chỉnh",
- "access_mode": "Chế độ truy cập",
- "mode_supervised": "Hỏi mỗi lần",
- "mode_unattended": "Truy cập không giám sát",
- "mode_disabled": "Đã tắt",
- "receive_support": "Nhận hỗ trợ",
- "receive_support_hint": "Yêu cầu trợ giúp hoặc trò chuyện với hỗ trợ. Bạn cũng có thể chia sẻ ID và mật khẩu bên dưới.",
- "or_share_id": "Hoặc chia sẻ ID của bạn",
- "share_id_password": "Chia sẻ ID và mật khẩu",
- "share_id_hint": "Chia sẻ thông tin này để hỗ trợ có thể kết nối với thiết bị của bạn.",
- "ongoing_session": "Phiên đang diễn ra",
- "close_session": "Đóng phiên",
- "request_help": "Yêu cầu trợ giúp",
- "help_message": "Mô tả vấn đề của bạn",
- "send": "Gửi",
- "cancel": "Hủy bỏ",
- "help_sent": "Đã gửi yêu cầu trợ giúp",
- "help_failed": "Không thể gửi yêu cầu trợ giúp",
- "connected": "Đã kết nối",
- "disconnected": "Đang kết nối...",
- "save": "Lưu",
- "settings": "Cài đặt",
- "test_connection": "Kiểm tra kết nối",
- "test_running": "Đang kiểm tra kết nối…",
- "test_ok": "Kết nối OK",
- "test_failed": "Sự cố kết nối",
- "test_gateway": "Cổng từ xa",
- "test_api": "API quản trị",
- "test_enrollment": "API đăng ký thiết bị",
- "close": "Đóng",
- "password_too_short": "Mật khẩu phải có ít nhất 6 ký tự",
- "status_ready": "Sẵn sàng kết nối (kết nối bảo mật)",
- "enrollment_pending": "Đang chờ",
- "enrollment_rejected": "Đã từ chối",
- "enrollment_error": "Đăng ký thất bại",
- "consent_title": "Thông tin chuyên gia",
- "consent_prompt": "Cho phép kết nối từ",
- "consent_ack": "Tôi đã đọc thông tin chuyên gia.",
- "consent_display_name": "Tên hiển thị",
- "consent_session": "Phiên",
- "session_active": "Phiên đang hoạt động",
- "session_with": "Phiên với",
- "session_disconnect": "Ẩn thanh phiên",
- "disconnect": "Ngắt kết nối",
- "chat_with_support": "Trò chuyện với sự hỗ trợ",
- "settings_language": "Ngôn ngữ",
- "quit": "Thoát",
- "consent_accept": "Tiếp tục",
- "consent_deny": "Hủy phiên",
- "chat_title": "Trò chuyện",
- "chat_send": "Gửi",
- "chat_placeholder": "Nhập tin nhắn...",
- "chat_empty": "Chưa có tin nhắn nào",
- "totp_title": "Xác thực hai yếu tố",
- "totp_enabled": "2FA đã bật trên thiết bị này",
- "totp_disabled": "2FA chưa bật",
- "totp_setup": "Thiết lập 2FA",
- "totp_disable": "Tắt 2FA",
- "totp_manual_key": "Khóa thủ công",
- "totp_step2": "Quét URI trong ứng dụng xác thực, sau đó nhập mã",
- "totp_enter_code": "Nhập mã xác minh",
- "totp_verify_enable": "Xác minh và bật",
- "totp_invalid_code": "Mã xác minh không hợp lệ",
- "totp_enabled_success": "Đã bật xác thực hai yếu tố",
- "totp_disabled_success": "Đã tắt xác thực hai yếu tố",
- "totp_setup_failed": "Không thể bắt đầu thiết lập 2FA",
- "totp_required": "Yêu cầu xác thực hai yếu tố",
- "totp_wrong_code": "Mã 2FA không hợp lệ"
-}
diff --git a/betterdesk-support-agent/locales/zh-TW.json b/betterdesk-support-agent/locales/zh-TW.json
deleted file mode 100644
index 3a77d390..00000000
--- a/betterdesk-support-agent/locales/zh-TW.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "支持",
- "your_id": "您的 ID",
- "access_password": "密碼",
- "show": "顯示",
- "hide": "隱藏",
- "copy": "複製",
- "copied": "已複製到剪貼板",
- "regenerate": "重新生成",
- "password_regenerated": "已產生新密碼",
- "set_custom": "設置自定義密碼",
- "custom_password": "自定義密碼",
- "access_mode": "訪問模式",
- "mode_supervised": "每次詢問",
- "mode_unattended": "無人值守訪問",
- "mode_disabled": "已禁用",
- "receive_support": "取得支援",
- "receive_support_hint": "可請求協助或與支援人員聊天,也可在下方分享您的 ID 與密碼。",
- "or_share_id": "或分享您的 ID",
- "share_id_password": "分享 ID 與密碼",
- "share_id_hint": "分享這些憑證,以便支援人員連線到您的裝置。",
- "ongoing_session": "進行中的工作階段",
- "close_session": "關閉工作階段",
- "request_help": "請求幫助",
- "help_message": "描述您的問題",
- "send": "發送",
- "cancel": "取消",
- "help_sent": "已發送幫助請求",
- "help_failed": "無法發送幫助請求",
- "connected": "已連接",
- "disconnected": "正在連接...",
- "save": "保存",
- "settings": "設置",
- "test_connection": "測試連接",
- "test_running": "正在測試連接…",
- "test_ok": "連接正常",
- "test_failed": "連接問題",
- "test_gateway": "遠程網關",
- "test_api": "管理 API",
- "test_enrollment": "設備註冊 API",
- "close": "關閉",
- "password_too_short": "密碼至少需要 6 個字元",
- "status_ready": "準備連線(安全連線)",
- "enrollment_pending": "待審批",
- "enrollment_rejected": "已拒絕",
- "enrollment_error": "註冊失敗",
- "consent_title": "專家資訊",
- "consent_prompt": "允許來自以下對象的連接",
- "consent_ack": "我已閱讀專家資訊。",
- "consent_display_name": "顯示名稱",
- "consent_session": "工作階段",
- "session_active": "活動會話",
- "session_with": "會話對象",
- "session_disconnect": "隱藏會話欄",
- "disconnect": "中斷連線",
- "chat_with_support": "與支持人員聊天",
- "settings_language": "語言",
- "quit": "退出(僅管理員)",
- "consent_accept": "繼續",
- "consent_deny": "取消工作階段",
- "chat_title": "聊天",
- "chat_send": "發送",
- "chat_placeholder": "輸入消息...",
- "chat_empty": "暫無消息",
- "totp_title": "雙因素認證",
- "totp_enabled": "此設備已啓用 2FA",
- "totp_disabled": "未啓用 2FA",
- "totp_setup": "設置 2FA",
- "totp_disable": "禁用 2FA",
- "totp_manual_key": "手動密鑰",
- "totp_step2": "在驗證器應用中掃描 URI,然後輸入驗證碼",
- "totp_enter_code": "驗證碼",
- "totp_verify_enable": "驗證並啓用",
- "totp_invalid_code": "驗證碼無效",
- "totp_enabled_success": "已啓用雙因素認證",
- "totp_disabled_success": "已禁用雙因素認證",
- "totp_setup_failed": "無法開始 2FA 設置",
- "totp_required": "需要雙因素認證",
- "totp_wrong_code": "2FA 驗證碼無效"
-}
diff --git a/betterdesk-support-agent/locales/zh.json b/betterdesk-support-agent/locales/zh.json
deleted file mode 100644
index 91cea0c0..00000000
--- a/betterdesk-support-agent/locales/zh.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "window_title": "支持",
- "your_id": "您的 ID",
- "access_password": "密码",
- "show": "显示",
- "hide": "隐藏",
- "copy": "复制",
- "copied": "已复制到剪贴板",
- "regenerate": "重新生成",
- "password_regenerated": "已生成新密码",
- "set_custom": "设置自定义密码",
- "custom_password": "自定义密码",
- "access_mode": "访问模式",
- "mode_supervised": "每次询问",
- "mode_unattended": "无人值守访问",
- "mode_disabled": "已禁用",
- "receive_support": "获取支持",
- "receive_support_hint": "可请求帮助或与支持人员聊天,也可在下方共享您的 ID 和密码。",
- "or_share_id": "或共享您的 ID",
- "share_id_password": "共享 ID 和密码",
- "share_id_hint": "共享这些凭据,以便支持人员连接到您的设备。",
- "ongoing_session": "进行中的会话",
- "close_session": "关闭会话",
- "request_help": "请求帮助",
- "help_message": "描述您的问题",
- "send": "发送",
- "cancel": "取消",
- "help_sent": "已发送帮助请求",
- "help_failed": "无法发送帮助请求",
- "connected": "已连接",
- "disconnected": "正在连接...",
- "save": "保存",
- "settings": "设置",
- "test_connection": "测试连接",
- "test_running": "正在测试连接…",
- "test_ok": "连接正常",
- "test_failed": "连接问题",
- "test_gateway": "远程网关",
- "test_api": "管理 API",
- "test_enrollment": "设备注册 API",
- "close": "关闭",
- "password_too_short": "密码至少需要 6 个字符",
- "status_ready": "准备连接(安全连接)",
- "enrollment_pending": "待审批",
- "enrollment_rejected": "已拒绝",
- "enrollment_error": "注册失败",
- "consent_title": "专家信息",
- "consent_prompt": "允许来自以下对象的连接",
- "consent_ack": "我已阅读专家信息。",
- "consent_display_name": "显示名称",
- "consent_session": "会话",
- "session_active": "活动会话",
- "session_with": "会话对象",
- "session_disconnect": "隐藏会话栏",
- "disconnect": "断开连接",
- "chat_with_support": "与支持人员聊天",
- "settings_language": "语言",
- "quit": "退出(仅管理员)",
- "consent_accept": "继续",
- "consent_deny": "取消会话",
- "chat_title": "聊天",
- "chat_send": "发送",
- "chat_placeholder": "输入消息...",
- "chat_empty": "暂无消息",
- "totp_title": "双因素认证",
- "totp_enabled": "此设备已启用 2FA",
- "totp_disabled": "未启用 2FA",
- "totp_setup": "设置 2FA",
- "totp_disable": "禁用 2FA",
- "totp_manual_key": "手动密钥",
- "totp_step2": "在验证器应用中扫描 URI,然后输入验证码",
- "totp_enter_code": "验证码",
- "totp_verify_enable": "验证并启用",
- "totp_invalid_code": "验证码无效",
- "totp_enabled_success": "已启用双因素认证",
- "totp_disabled_success": "已禁用双因素认证",
- "totp_setup_failed": "无法开始 2FA 设置",
- "totp_required": "需要双因素认证",
- "totp_wrong_code": "2FA 验证码无效"
-}
diff --git a/betterdesk-support-agent/main.go b/betterdesk-support-agent/main.go
deleted file mode 100644
index 7aebf77e..00000000
--- a/betterdesk-support-agent/main.go
+++ /dev/null
@@ -1,82 +0,0 @@
-// BetterDesk Support Agent — lightweight quick-help remote desktop agent.
-//
-// Single Go binary that serves two distribution forms from one codebase:
-//
-// - Installer form: registered as an autostarting background service that
-// presents a minimal "quick help" window (request help, show access
-// password, supervised/unattended access, custom password).
-// - Portable form: the exact same binary run directly, no installation.
-//
-// The remote-desktop engine is reused from the betterdesk-agent module.
-package main
-
-import (
- "flag"
- "fmt"
- "os"
-)
-
-var version = "0.1.0"
-
-func main() {
- var (
- showVer = flag.Bool("version", false, "Print version and exit")
- doInstall = flag.Bool("install", false, "Install to a per-user location and enable autostart")
- doUninst = flag.Bool("uninstall", false, "Remove autostart entry and installed binary")
- doPurge = flag.Bool("purge", false, "With -uninstall, also remove persistent state")
- doReset = flag.Bool("reset-enrollment", false, "Clear local enrollment state and exit")
- noGUI = flag.Bool("nogui", false, "Run without graphical interface (no window)")
- )
- flag.Parse()
- if *doPurge && !*doUninst {
- fmt.Fprintln(os.Stderr, "-purge requires -uninstall")
- os.Exit(2)
- }
-
- antiDebugChecks()
- prepWindowsGraphics()
- prepLinuxDisplay()
-
- if *showVer {
- fmt.Printf("betterdesk-support-agent %s\n", version)
- os.Exit(0)
- }
-
- if *doInstall {
- if err := Install(); err != nil {
- fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
- os.Exit(1)
- }
- os.Exit(0)
- }
-
- if *doUninst {
- var err error
- if *doPurge {
- err = UninstallPurge()
- } else {
- err = Uninstall()
- }
- if err != nil {
- fmt.Fprintf(os.Stderr, "uninstall failed: %v\n", err)
- os.Exit(1)
- }
- os.Exit(0)
- }
-
- if *doReset {
- if err := ResetEnrollmentState(); err != nil {
- fmt.Fprintf(os.Stderr, "reset-enrollment failed: %v\n", err)
- os.Exit(1)
- }
- fmt.Println("Local enrollment state cleared.")
- os.Exit(0)
- }
-
- if *noGUI || os.Getenv("BETTERDESK_SUPPORT_NOGUI") == "1" {
- runHeadless()
- return
- }
-
- run()
-}
diff --git a/betterdesk-support-agent/mesa_dll_embed.go b/betterdesk-support-agent/mesa_dll_embed.go
deleted file mode 100644
index cb27ac43..00000000
--- a/betterdesk-support-agent/mesa_dll_embed.go
+++ /dev/null
@@ -1,43 +0,0 @@
-//go:build windows && mesaembed
-
-package main
-
-import (
- "embed"
- "io/fs"
- "log"
- "os"
- "path/filepath"
-)
-
-// Complete Mesa software-OpenGL set. opengl32.dll alone depends on
-// libgallium_wgl.dll — shipping only opengl32 causes STATUS_DLL_NOT_FOUND
-// because Windows prefers the local broken DLL over system OpenGL.
-//
-//go:embed windows/*.dll
-var mesaDLLFS embed.FS
-
-func ensureMesaBesideExe() {
- dir := exeDir()
- entries, err := fs.ReadDir(mesaDLLFS, "windows")
- if err != nil {
- log.Printf("[support-agent] mesa embed listing failed: %v", err)
- return
- }
- for _, e := range entries {
- if e.IsDir() || filepath.Ext(e.Name()) != ".dll" {
- continue
- }
- target := filepath.Join(dir, e.Name())
- if _, err := os.Stat(target); err == nil {
- continue
- }
- data, err := mesaDLLFS.ReadFile("windows/" + e.Name())
- if err != nil || len(data) == 0 {
- continue
- }
- if err := os.WriteFile(target, data, 0o644); err != nil {
- log.Printf("[support-agent] could not write %s: %v", e.Name(), err)
- }
- }
-}
diff --git a/betterdesk-support-agent/mesa_dll_stub.go b/betterdesk-support-agent/mesa_dll_stub.go
deleted file mode 100644
index d9547206..00000000
--- a/betterdesk-support-agent/mesa_dll_stub.go
+++ /dev/null
@@ -1,5 +0,0 @@
-//go:build windows && !mesaembed
-
-package main
-
-func ensureMesaBesideExe() {}
diff --git a/betterdesk-support-agent/mesa_other.go b/betterdesk-support-agent/mesa_other.go
deleted file mode 100644
index 6521170c..00000000
--- a/betterdesk-support-agent/mesa_other.go
+++ /dev/null
@@ -1,5 +0,0 @@
-//go:build !windows
-
-package main
-
-func ensureMesaBesideExe() {}
diff --git a/betterdesk-support-agent/mesa_windows.go b/betterdesk-support-agent/mesa_windows.go
deleted file mode 100644
index c04dbf22..00000000
--- a/betterdesk-support-agent/mesa_windows.go
+++ /dev/null
@@ -1,16 +0,0 @@
-//go:build windows
-
-package main
-
-import (
- "os"
- "path/filepath"
-)
-
-func exeDir() string {
- exe, err := os.Executable()
- if err != nil {
- return "."
- }
- return filepath.Dir(exe)
-}
diff --git a/betterdesk-support-agent/netcheck.go b/betterdesk-support-agent/netcheck.go
deleted file mode 100644
index 08844fd8..00000000
--- a/betterdesk-support-agent/netcheck.go
+++ /dev/null
@@ -1,145 +0,0 @@
-package main
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "strings"
- "time"
-)
-
-type ProbeResult struct {
- OK bool
- Detail string
- Latency time.Duration
-}
-
-type ConnCheck struct {
- CDAP ProbeResult
- API ProbeResult
-}
-
-type ExtendedConnCheck struct {
- CDAP ProbeResult
- API ProbeResult
- Enrollment ProbeResult
-}
-
-func (c ConnCheck) AllOK() bool {
- return c.CDAP.OK && c.API.OK
-}
-
-func (c ExtendedConnCheck) AllOK() bool {
- return c.CDAP.OK && c.API.OK && c.Enrollment.OK
-}
-
-// TestConnection probes the CDAP gateway and the Go management API.
-func TestConnection(b Branding) ConnCheck {
- return ConnCheck{
- CDAP: probeHealth(b.CDAPHealthURL()),
- API: probeHealth(b.APIHealthURL()),
- }
-}
-
-// TestConnectionExtended includes enrollment reachability for the device API
-// and prefers last-known-good endpoints when available.
-func TestConnectionExtended(b Branding, st *AppState) ExtendedConnCheck {
- _, cdapProbe := PickWorkingCDAP(b, st)
- apiBase, apiProbe := PickWorkingAPI(b, st)
- res := ExtendedConnCheck{
- CDAP: cdapProbe,
- API: apiProbe,
- }
- if !b.HasConnection() {
- res.Enrollment = ProbeResult{OK: false, Detail: "no server configured"}
- return res
- }
- deviceID, _, _, _ := st.Snapshot()
- url := fmt.Sprintf("%s/devices/register/status?device_id=%s", apiBase, deviceID)
- _, latency, err := httpGet(url)
- if err != nil {
- // Fallback to branded API base if last-good drifted.
- url = fmt.Sprintf("%s/devices/register/status?device_id=%s", apiBaseURL(b), deviceID)
- _, latency, err = httpGet(url)
- }
- if err != nil {
- res.Enrollment = ProbeResult{OK: false, Detail: shortenErr(err.Error()), Latency: latency}
- return res
- }
- res.Enrollment = ProbeResult{OK: true, Detail: "register API reachable", Latency: latency}
- return res
-}
-
-func probeHealth(endpoint string) ProbeResult {
- body, latency, err := httpGet(endpoint)
- if err != nil {
- return ProbeResult{OK: false, Detail: shortenErr(err.Error()), Latency: latency}
- }
- var parsed struct {
- Status string `json:"status"`
- }
- if json.Unmarshal(body, &parsed) != nil || parsed.Status != "ok" {
- return ProbeResult{OK: false, Detail: "unexpected response", Latency: latency}
- }
- detail := "ok"
- if strings.Contains(endpoint, "/cdap/health") {
- detail = "gateway ok"
- } else if strings.Contains(endpoint, "/api/health") {
- detail = "api ok"
- }
- return ProbeResult{OK: true, Detail: detail, Latency: latency}
-}
-
-func shortenErr(msg string) string {
- msg = strings.TrimSpace(msg)
- if len(msg) <= 120 {
- return msg
- }
- return msg[:117] + "…"
-}
-
-func httpGet(endpoint string) ([]byte, time.Duration, error) {
- ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
- defer cancel()
-
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return nil, 0, err
- }
-
- client := healthHTTPClient(endpoint)
-
- start := time.Now()
- resp, err := client.Do(req)
- latency := time.Since(start)
- if err != nil {
- return nil, latency, err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return nil, latency, fmt.Errorf("HTTP %d", resp.StatusCode)
- }
- buf := make([]byte, 0, 512)
- tmp := make([]byte, 512)
- for {
- n, rerr := resp.Body.Read(tmp)
- buf = append(buf, tmp[:n]...)
- if rerr != nil || len(buf) > 4096 {
- break
- }
- }
- return buf, latency, nil
-}
-
-func healthHTTPClient(endpoint string) *http.Client {
- if !strings.HasPrefix(strings.ToLower(endpoint), "https://") {
- return &http.Client{Timeout: 8 * time.Second}
- }
- pin := ""
- if b := GetBranding(); b.Server != nil {
- pin = b.Server.CertPin
- }
- return apiHTTPClientWithPin(8*time.Second, pin)
-}
diff --git a/betterdesk-support-agent/passive_session.go b/betterdesk-support-agent/passive_session.go
deleted file mode 100644
index f8caf3c6..00000000
--- a/betterdesk-support-agent/passive_session.go
+++ /dev/null
@@ -1,182 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "strings"
- "sync"
-
- "github.com/unitronix/betterdesk-support-agent/internal/sessioncore"
-)
-
-const supportSessionGrantAudience = "betterdesk-support-agent"
-
-// passiveSessionAuthorizer bridges the generic embedded CDAP engine to the
-// independently-owned Support Agent session core. It retains no grant token:
-// grants are verified once, scoped to one session, and represented afterward
-// only by non-secret audit events.
-type passiveSessionAuthorizer struct {
- mu sync.Mutex
- deviceID string
- allowedCapabilities []sessioncore.Capability
- verifier sessioncore.GrantVerifier
- sessions map[string]*sessioncore.Core
-}
-
-func newPassiveSessionAuthorizer(brand Branding, st *AppState) (*passiveSessionAuthorizer, error) {
- if st == nil {
- return nil, fmt.Errorf("missing application state")
- }
- st.mu.Lock()
- deviceID := strings.TrimSpace(st.DeviceID)
- st.mu.Unlock()
- publicKey := strings.TrimSpace(brand.ServerKey)
- if publicKey == "" && brand.Server != nil {
- publicKey = strings.TrimSpace(brand.Server.PublicKey)
- }
- verifier, err := sessioncore.NewEd25519GrantVerifier(publicKey)
- if err != nil {
- return nil, fmt.Errorf("configure session-grant verifier: %w", err)
- }
- capabilities := policySessionCapabilities(accessPolicyFor(brand, st))
- if len(capabilities) == 0 {
- return nil, fmt.Errorf("desktop capability is disabled")
- }
- return &passiveSessionAuthorizer{
- deviceID: deviceID,
- allowedCapabilities: capabilities,
- verifier: verifier,
- sessions: make(map[string]*sessioncore.Core),
- }, nil
-}
-
-func policySessionCapabilities(policy incomingAccessPolicy) []sessioncore.Capability {
- capabilities := make([]sessioncore.Capability, 0, 8)
- if policy.capabilities.Desktop {
- capabilities = append(capabilities, sessioncore.CapabilityScreenView, sessioncore.CapabilityInput)
- }
- if policy.capabilities.Audio {
- capabilities = append(capabilities, sessioncore.CapabilitySystemAudio)
- }
- if policy.capabilities.Clipboard {
- capabilities = append(capabilities, sessioncore.CapabilityClipboard)
- }
- if policy.capabilities.Files {
- capabilities = append(capabilities, sessioncore.CapabilityFiles)
- }
- if policy.capabilities.Terminal {
- capabilities = append(capabilities, sessioncore.CapabilityTerminal)
- }
- if policy.capabilities.Restart {
- capabilities = append(capabilities, sessioncore.CapabilityRestart)
- }
- return capabilities
-}
-
-func (a *passiveSessionAuthorizer) Authorize(
- sessionID, operatorID, transport string,
- requested []string,
- grant string,
- requireConsent bool,
-) error {
- if a == nil {
- return fmt.Errorf("passive session authorizer is unavailable")
- }
- sessionID = strings.TrimSpace(sessionID)
- if sessionID == "" {
- return fmt.Errorf("missing session ID")
- }
- requestedCapabilities := make([]sessioncore.Capability, 0, len(requested))
- for _, capability := range requested {
- requestedCapabilities = append(requestedCapabilities, sessioncore.Capability(strings.TrimSpace(capability)))
- }
-
- core, err := sessioncore.New(sessioncore.Config{
- Audience: supportSessionGrantAudience,
- DeviceID: a.deviceID,
- AllowedCapabilities: a.allowedCapabilities,
- Verifier: a.verifier,
- EventSink: logPassiveSessionEvent,
- })
- if err != nil {
- return err
- }
- if err := core.BeginEnrollment(); err != nil {
- return err
- }
- if err := core.ApproveEnrollment(); err != nil {
- return err
- }
- if _, err := core.Authorize(context.Background(), sessioncore.AdmissionRequest{
- OperatorID: operatorID,
- SessionID: sessionID,
- Transport: transport,
- RequestedCapabilities: requestedCapabilities,
- GrantPresentation: grant,
- }); err != nil {
- return err
- }
- if err := core.RequestConsent(); err != nil {
- return err
- }
- if !requireConsent {
- if err := core.GrantConsent(); err != nil {
- return err
- }
- }
-
- a.mu.Lock()
- if existing := a.sessions[sessionID]; existing != nil {
- existing.Disconnect()
- }
- a.sessions[sessionID] = core
- a.mu.Unlock()
- return nil
-}
-
-func (a *passiveSessionAuthorizer) ResolveConsent(sessionID string, granted bool) {
- core := a.lookup(sessionID)
- if core == nil {
- return
- }
- if granted {
- _ = core.GrantConsent()
- return
- }
- core.DenyConsent()
- a.remove(sessionID, core)
-}
-
-func (a *passiveSessionAuthorizer) End(sessionID string) {
- core := a.lookup(sessionID)
- if core == nil {
- return
- }
- core.Disconnect()
- a.remove(sessionID, core)
-}
-
-func (a *passiveSessionAuthorizer) lookup(sessionID string) *sessioncore.Core {
- a.mu.Lock()
- defer a.mu.Unlock()
- return a.sessions[sessionID]
-}
-
-func (a *passiveSessionAuthorizer) remove(sessionID string, expected *sessioncore.Core) {
- a.mu.Lock()
- defer a.mu.Unlock()
- if a.sessions[sessionID] == expected {
- delete(a.sessions, sessionID)
- }
-}
-
-func logPassiveSessionEvent(event sessioncore.Event) {
- appLogInfo("passive_session", string(event.Kind), map[string]any{
- "session_id": event.SessionID,
- "device_id": event.DeviceID,
- "operator_id": event.OperatorID,
- "from": string(event.From),
- "to": string(event.To),
- "reason": event.Reason,
- })
-}
diff --git a/betterdesk-support-agent/password_sync.go b/betterdesk-support-agent/password_sync.go
deleted file mode 100644
index 933a9418..00000000
--- a/betterdesk-support-agent/password_sync.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package main
-
-import (
- "fmt"
- "net/http"
-)
-
-// SyncAccessPassword publishes only the local password-policy state. The
-// unattended secret is deliberately verified by the Support Agent and never
-// sent to, stored by, or logged by the server.
-func SyncAccessPassword(b Branding, st *AppState) error {
- deviceID, _, _, _ := st.Snapshot()
- st.mu.Lock()
- token := st.DeviceToken
- st.mu.Unlock()
-
- if token == "" {
- return fmt.Errorf("device not enrolled")
- }
- policy := accessPolicyFor(b, st)
-
- payload := map[string]any{
- "device_id": deviceID,
- "device_token": token,
- "password_set": policy.passwordConfigured,
- "unattended_enabled": policy.allowsUnattended(),
- }
- url := apiBaseURL(b) + "/devices/self/access-policy"
- code, err := apiJSON(http.MethodPost, url, payload, nil)
- if err != nil {
- return err
- }
- if code != http.StatusOK && code != http.StatusNoContent {
- return fmt.Errorf("access policy sync failed (HTTP %d)", code)
- }
- return nil
-}
diff --git a/betterdesk-support-agent/password_sync_test.go b/betterdesk-support-agent/password_sync_test.go
deleted file mode 100644
index f88c84ae..00000000
--- a/betterdesk-support-agent/password_sync_test.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-)
-
-func TestSyncAccessPasswordPublishesEffectiveUnattendedPolicy(t *testing.T) {
- if isReleaseBuild() {
- t.Skip("HTTP test server is intentionally refused by release transport policy")
- }
-
- var payload map[string]any
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost || r.URL.Path != "/api/devices/self/access-policy" {
- t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
- }
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
- t.Fatalf("decode policy payload: %v", err)
- }
- w.WriteHeader(http.StatusNoContent)
- }))
- defer server.Close()
-
- st := &AppState{
- DeviceID: "BD-TEST",
- DeviceToken: "device-token",
- AccessMode: AccessUnattended,
- AccessPassword: " \t ",
- }
- brand := Branding{
- AllowUnattended: true,
- Server: &ServerBranding{APIURL: server.URL},
- }
- if err := SyncAccessPassword(brand, st); err != nil {
- t.Fatalf("SyncAccessPassword: %v", err)
- }
-
- if got, want := payload["password_set"], false; got != want {
- t.Fatalf("password_set = %v, want %v", got, want)
- }
- if got, want := payload["unattended_enabled"], false; got != want {
- t.Fatalf("unattended_enabled = %v, want %v", got, want)
- }
- if _, leaked := payload["password"]; leaked {
- t.Fatal("access policy payload must not contain the local password")
- }
-}
-
-func TestSyncAccessPasswordNeverSendsLocalSecret(t *testing.T) {
- if isReleaseBuild() {
- t.Skip("HTTP test server is intentionally refused by release transport policy")
- }
-
- var received map[string]any
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/api/devices/self/access-policy" {
- t.Fatalf("path = %q", r.URL.Path)
- }
- if err := json.NewDecoder(r.Body).Decode(&received); err != nil {
- t.Fatal(err)
- }
- w.WriteHeader(http.StatusNoContent)
- }))
- defer server.Close()
-
- state := &AppState{
- DeviceID: "BD-LOCAL-SECRET",
- DeviceToken: "device-token",
- AccessPassword: "must-remain-local",
- AccessMode: AccessUnattended,
- }
- brand := Branding{Server: &ServerBranding{APIURL: server.URL}}
- if err := SyncAccessPassword(brand, state); err != nil {
- t.Fatal(err)
- }
- if _, found := received["password"]; found {
- t.Fatalf("password was sent: %#v", received)
- }
- if received["password_set"] != true {
- t.Fatalf("password_set = %#v", received["password_set"])
- }
-}
diff --git a/betterdesk-support-agent/release.go b/betterdesk-support-agent/release.go
deleted file mode 100644
index 39070f1c..00000000
--- a/betterdesk-support-agent/release.go
+++ /dev/null
@@ -1,5 +0,0 @@
-//go:build release
-
-package main
-
-const releaseBuild = true
diff --git a/betterdesk-support-agent/release_dev.go b/betterdesk-support-agent/release_dev.go
deleted file mode 100644
index c22c7ae1..00000000
--- a/betterdesk-support-agent/release_dev.go
+++ /dev/null
@@ -1,5 +0,0 @@
-//go:build !release
-
-package main
-
-const releaseBuild = false
diff --git a/betterdesk-support-agent/resources/branding.json b/betterdesk-support-agent/resources/branding.json
deleted file mode 100644
index 7db9ebbd..00000000
--- a/betterdesk-support-agent/resources/branding.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "product_name": "BetterDesk Support",
- "company_name": "BetterDesk",
- "tagline": "Quick remote help",
- "support_email": "",
- "support_phone": "",
- "contact_url": "",
- "primary_color": "#2563eb",
- "accent_color": "#e0f2fe",
- "background_color": "#ffffff",
- "surface_color": "#f3f4f6",
- "text_color": "#1f2937",
- "text_muted_color": "#6b7280",
- "status_ready_color": "#22c55e",
- "header_text_color": "#1f2937",
- "logo_data_url": "",
- "default_language": "en",
- "allow_unattended": false,
- "capabilities": {
- "desktop": true,
- "files": true,
- "clipboard": true,
- "audio": true,
- "terminal": true,
- "restart": true
- },
- "server": {
- "address": "",
- "api_url": "",
- "public_key": ""
- },
- "api_key": "",
- "bundle_id": ""
-}
diff --git a/betterdesk-support-agent/scripts/betterdesk-support-launcher.sh b/betterdesk-support-agent/scripts/betterdesk-support-launcher.sh
deleted file mode 100644
index 76544b56..00000000
--- a/betterdesk-support-agent/scripts/betterdesk-support-launcher.sh
+++ /dev/null
@@ -1,31 +0,0 @@
-#!/bin/sh
-# Picks the Fyne binary matching the active Linux session (Wayland vs X11).
-set -eu
-HERE="$(cd "$(dirname "$0")" && pwd)"
-X11="$HERE/betterdesk-support-x11"
-WL="$HERE/betterdesk-support-wayland"
-LEGACY="$HERE/betterdesk-support.bin"
-
-if [ -n "${BETTERDESK_UI_BACKEND:-}" ]; then
- case "$BETTERDESK_UI_BACKEND" in
- wayland|wl) BACKEND=wayland ;;
- x11|x) BACKEND=x11 ;;
- *) BACKEND=x11 ;;
- esac
-elif [ -n "${WAYLAND_DISPLAY:-}" ] || [ "${XDG_SESSION_TYPE:-}" = "wayland" ]; then
- BACKEND=wayland
-else
- BACKEND=x11
-fi
-
-if [ "$BACKEND" = wayland ] && [ -x "$WL" ]; then
- exec "$WL" "$@"
-fi
-if [ -x "$X11" ]; then
- exec "$X11" "$@"
-fi
-if [ -x "$LEGACY" ]; then
- exec "$LEGACY" "$@"
-fi
-echo "betterdesk-support: no UI binary found in $HERE" >&2
-exit 1
diff --git a/betterdesk-support-agent/scripts/sync-locales.mjs b/betterdesk-support-agent/scripts/sync-locales.mjs
deleted file mode 100644
index cb43dd1b..00000000
--- a/betterdesk-support-agent/scripts/sync-locales.mjs
+++ /dev/null
@@ -1,82 +0,0 @@
-#!/usr/bin/env node
-/**
- * Build support-agent locale JSON files from en.json, web-nodejs/lang/*.json,
- * and locales/supplemental.json (strings without a console equivalent).
- */
-import fs from 'node:fs';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const root = path.join(__dirname, '..');
-const webDir = path.join(root, '..', 'web-nodejs', 'lang');
-const localesDir = path.join(root, 'locales');
-
-const LANGS = [
- 'ar', 'cs', 'da', 'de', 'en', 'es', 'fi', 'fr', 'hi', 'hu', 'id', 'it',
- 'ja', 'ko', 'nb', 'nl', 'pl', 'pt', 'ro', 'sv', 'th', 'tr', 'uk', 'vi',
- 'zh', 'zh-TW',
-];
-
-const en = JSON.parse(fs.readFileSync(path.join(localesDir, 'en.json'), 'utf8'));
-const supplemental = JSON.parse(
- fs.readFileSync(path.join(localesDir, 'supplemental.json'), 'utf8'),
-);
-
-function get(obj, ...keys) {
- let cur = obj;
- for (const k of keys) {
- if (cur == null || typeof cur !== 'object') return undefined;
- cur = cur[k];
- }
- return typeof cur === 'string' ? cur : undefined;
-}
-
-/** Map support-agent keys to web-nodejs JSON paths. */
-const WEB_MAP = {
- save: (w) => get(w, 'common', 'save'),
- cancel: (w) => get(w, 'common', 'cancel'),
- close: (w) => get(w, 'common', 'close'),
- copied: (w) => get(w, 'common', 'copied'),
- copy: (w) => get(w, 'actions', 'copy'),
- settings: (w) => get(w, 'nav', 'settings'),
- send: (w) => get(w, 'chat', 'send'),
- chat_title: (w) => get(w, 'chat', 'title'),
- chat_send: (w) => get(w, 'chat', 'send'),
- chat_placeholder: (w) => get(w, 'chat', 'type_message'),
- chat_empty: (w) => get(w, 'chat', 'no_messages'),
- your_id: (w) => get(w, 'generator', 'preview_id'),
- access_password: (w) => get(w, 'generator', 'preview_password'),
- chat_with_support: (w) => get(w, 'generator', 'preview_chat'),
- request_help: (w) => get(w, 'generator', 'preview_help'),
- status_ready: (w) => get(w, 'generator', 'preview_status_ready'),
- connected: (w) => get(w, 'cdap', 'connected'),
- disconnected: (w) => get(w, 'remote', 'connecting'),
- settings_language: (w) => get(w, 'settings', 'language'),
- enrollment_pending: (w) => get(w, 'registrations', 'status_pending'),
- enrollment_rejected: (w) => get(w, 'registrations', 'status_rejected'),
- consent_accept: (w) => get(w, 'registrations', 'approve_btn'),
- consent_deny: (w) => get(w, 'registrations', 'reject_btn'),
-};
-
-for (const lang of LANGS) {
- let web = {};
- const webPath = path.join(webDir, `${lang}.json`);
- if (fs.existsSync(webPath)) {
- web = JSON.parse(fs.readFileSync(webPath, 'utf8'));
- }
- const sup = supplemental[lang] || supplemental.en || {};
- const out = {};
- for (const key of Object.keys(en)) {
- out[key] =
- sup[key] ||
- WEB_MAP[key]?.(web) ||
- (lang === 'en' ? en[key] : undefined) ||
- en[key];
- }
- fs.writeFileSync(
- path.join(localesDir, `${lang}.json`),
- JSON.stringify(out, null, 2) + '\n',
- );
- console.log('wrote', lang);
-}
diff --git a/betterdesk-support-agent/session_overlay.go b/betterdesk-support-agent/session_overlay.go
deleted file mode 100644
index 9d40223e..00000000
--- a/betterdesk-support-agent/session_overlay.go
+++ /dev/null
@@ -1,75 +0,0 @@
-//go:build fyneui
-
-package main
-
-import (
- "sync"
- "time"
-
- "fyne.io/fyne/v2"
- "fyne.io/fyne/v2/container"
- "fyne.io/fyne/v2/widget"
-)
-
-// sessionOverlay shows an always-on-top bar during remote sessions.
-type sessionOverlay struct {
- win fyne.Window
- label *widget.Label
- operator string
- start time.Time
- ticker *time.Ticker
- mu sync.Mutex
- onDisconnect func()
-}
-
-func newSessionOverlay(app fyne.App, productName string, onDisconnect func()) *sessionOverlay {
- o := &sessionOverlay{start: time.Now(), onDisconnect: onDisconnect}
- o.win = app.NewWindow(productName + " — " + t("session_active"))
- o.win.SetFixedSize(true)
- o.label = widget.NewLabel("")
- disconnect := widget.NewButton(t("session_disconnect"), func() {
- o.requestDisconnect()
- o.hide()
- })
- o.win.SetContent(container.NewVBox(o.label, disconnect))
- o.win.SetCloseIntercept(func() { o.win.Hide() })
- return o
-}
-
-func (o *sessionOverlay) requestDisconnect() {
- if o.onDisconnect != nil {
- o.onDisconnect()
- }
-}
-
-func (o *sessionOverlay) show(operator, mode string) {
- o.mu.Lock()
- o.operator = operator
- o.start = time.Now()
- o.mu.Unlock()
-
- o.updateLabel(mode)
- if o.ticker != nil {
- o.ticker.Stop()
- }
- o.ticker = time.NewTicker(time.Second)
- go func() {
- for range o.ticker.C {
- o.updateLabel(mode)
- }
- }()
- o.win.Show()
-}
-
-func (o *sessionOverlay) hide() {
- if o.ticker != nil {
- o.ticker.Stop()
- o.ticker = nil
- }
- o.win.Hide()
-}
-
-func (o *sessionOverlay) updateLabel(mode string) {
- elapsed := time.Since(o.start).Truncate(time.Second)
- o.label.SetText(t("session_with") + " " + o.operator + " · " + mode + " · " + elapsed.String())
-}
diff --git a/betterdesk-support-agent/session_overlay_test.go b/betterdesk-support-agent/session_overlay_test.go
deleted file mode 100644
index 1deb7208..00000000
--- a/betterdesk-support-agent/session_overlay_test.go
+++ /dev/null
@@ -1,19 +0,0 @@
-//go:build fyneui
-
-package main
-
-import "testing"
-
-func TestSessionOverlayDisconnectInvokesTerminationCallback(t *testing.T) {
- calls := 0
- overlay := &sessionOverlay{
- onDisconnect: func() {
- calls++
- },
- }
-
- overlay.requestDisconnect()
- if calls != 1 {
- t.Fatalf("disconnect callback calls = %d, want 1", calls)
- }
-}
diff --git a/betterdesk-support-agent/signal.go b/betterdesk-support-agent/signal.go
deleted file mode 100644
index 182fe3fc..00000000
--- a/betterdesk-support-agent/signal.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package main
-
-import (
- "encoding/hex"
- "time"
-
- "github.com/unitronix/betterdesk-support-agent/internal/totp"
- "github.com/unitronix/betterdesk-support-agent/signalhost"
-)
-
-type signalHostCallbacks struct {
- consent func(operator string) bool
- onSession func(start bool, operator string)
- audit func(policy hostCapabilityPolicy)
-}
-
-// newSignalHost builds an outbound-only RustDesk-compatible relay host when
-// the current local access policy permits it.
-func newSignalHost(brand Branding, st *AppState, headless bool, callbacks signalHostCallbacks) (*signalhost.Host, string) {
- if !brand.HasConnection() {
- return nil, "no server is configured"
- }
- policy := accessPolicyFor(brand, st)
- if callbacks.audit != nil {
- callbacks.audit(hostCapabilityPolicyFor(brand))
- }
- if !policy.allowsSignalHost(headless) {
- return nil, policy.signalHostDisabledReason(headless)
- }
-
- deviceID, _, _, _ := st.Snapshot()
- uuidBytes, _ := hex.DecodeString(st.GetMachineUUID())
- return signalhost.New(signalhost.Config{
- SignalAddr: signalAddress(brand),
- RelayAddr: relayAddress(brand),
- DeviceID: deviceID,
- UUID: uuidBytes,
- DataDir: stateDir(),
- Password: func() string {
- _, _, pw, _ := st.Snapshot()
- return pw
- },
- Unattended: func() bool {
- return accessPolicyFor(brand, st).allowsUnattended()
- },
- TOTPEnabled: func() bool {
- enabled, _ := st.TOTPSnapshot()
- return enabled
- },
- TOTPVerify: func(code string) bool {
- _, secret := st.TOTPSnapshot()
- return secret != "" && totp.Validate(secret, code, time.Now())
- },
- AccessAllowed: func() bool {
- return accessPolicyFor(brand, st).allowsSignalHost(headless)
- },
- DesktopEnabled: policy.capabilities.Desktop,
- AudioEnabled: policy.capabilities.Audio,
- RestartEnabled: policy.capabilities.Restart,
- Consent: callbacks.consent,
- OnSession: callbacks.onSession,
- }), ""
-}
-
-func signalAddress(b Branding) string {
- return hostFromAddr(b.ServerAddress) + ":21116"
-}
-
-func relayAddress(b Branding) string {
- return hostFromAddr(b.ServerAddress) + ":21117"
-}
diff --git a/betterdesk-support-agent/signal_fyne.go b/betterdesk-support-agent/signal_fyne.go
deleted file mode 100644
index 836af0eb..00000000
--- a/betterdesk-support-agent/signal_fyne.go
+++ /dev/null
@@ -1,54 +0,0 @@
-//go:build fyneui
-
-package main
-
-func (u *ui) startSignalHost() {
- u.signalHostMu.Lock()
- defer u.signalHostMu.Unlock()
- if u.signalHost != nil {
- return
- }
- host, _ := newSignalHost(u.brand, u.state, false, signalHostCallbacks{
- consent: func(operator string) bool {
- return u.handleConsent("signal", operator)
- },
- audit: func(policy hostCapabilityPolicy) {
- auditHostCapabilityPolicy(hostCapabilityAuditTransportSignal, policy)
- },
- onSession: func(start bool, operator string) {
- if start {
- _, mode, _, _ := u.state.Snapshot()
- u.handleSessionStart("signal", operator, mode)
- } else {
- u.handleSessionEnd("signal")
- }
- },
- })
- if host == nil || !host.Start() {
- return
- }
- u.signalHost = host
- appLogInfo("signal_host", "signal/relay host started", map[string]any{
- "signal": signalAddress(u.brand),
- "relay": relayAddress(u.brand),
- })
-}
-
-func (u *ui) stopSignalHost() {
- u.signalHostMu.Lock()
- host := u.signalHost
- u.signalHost = nil
- u.signalHostMu.Unlock()
- if host != nil {
- host.Stop()
- }
-}
-
-func (u *ui) disconnectSignalSessions() {
- u.signalHostMu.Lock()
- host := u.signalHost
- u.signalHostMu.Unlock()
- if host != nil {
- host.DisconnectSessions()
- }
-}
diff --git a/betterdesk-support-agent/signalhost/auth_limiter.go b/betterdesk-support-agent/signalhost/auth_limiter.go
deleted file mode 100644
index 5ff13087..00000000
--- a/betterdesk-support-agent/signalhost/auth_limiter.go
+++ /dev/null
@@ -1,127 +0,0 @@
-package signalhost
-
-import (
- "strings"
- "sync"
- "time"
-)
-
-const (
- authFailureWindow = 5 * time.Minute
- authLockoutPeriod = 15 * time.Minute
- maxAuthFailures = 5
- maxTrackedAuthKeys = 1024
-)
-
-type authenticationAttempt struct {
- failures int
- windowStart time.Time
- lockedUntil time.Time
- lastSeen time.Time
-}
-
-// authenticationLimiter bounds repeated credential failures without retaining
-// credentials, TOTP values, or session contents. RustDesk-compatible relay
-// handshakes do not expose an end-client address to the host, so keys use the
-// claimed operator identity and the map is deliberately bounded.
-type authenticationLimiter struct {
- mu sync.Mutex
- now func() time.Time
- attempts map[string]authenticationAttempt
-}
-
-func newAuthenticationLimiter(now func() time.Time) *authenticationLimiter {
- if now == nil {
- now = time.Now
- }
- return &authenticationLimiter{
- now: now,
- attempts: make(map[string]authenticationAttempt),
- }
-}
-
-func (l *authenticationLimiter) allow(operator string) bool {
- if l == nil {
- return true
- }
- key := authenticationAttemptKey(operator)
- now := l.now()
-
- l.mu.Lock()
- defer l.mu.Unlock()
- l.pruneLocked(now)
- entry, ok := l.attempts[key]
- if !ok {
- return true
- }
- entry.lastSeen = now
- l.attempts[key] = entry
- return entry.lockedUntil.IsZero() || !now.Before(entry.lockedUntil)
-}
-
-func (l *authenticationLimiter) failure(operator string) {
- if l == nil {
- return
- }
- key := authenticationAttemptKey(operator)
- now := l.now()
-
- l.mu.Lock()
- defer l.mu.Unlock()
- l.pruneLocked(now)
- entry := l.attempts[key]
- if entry.windowStart.IsZero() || now.Sub(entry.windowStart) >= authFailureWindow {
- entry.failures = 0
- entry.windowStart = now
- }
- entry.failures++
- entry.lastSeen = now
- if entry.failures >= maxAuthFailures {
- entry.lockedUntil = now.Add(authLockoutPeriod)
- entry.failures = 0
- entry.windowStart = now
- }
- l.attempts[key] = entry
-}
-
-func (l *authenticationLimiter) success(operator string) {
- if l == nil {
- return
- }
- l.mu.Lock()
- defer l.mu.Unlock()
- delete(l.attempts, authenticationAttemptKey(operator))
-}
-
-func (l *authenticationLimiter) pruneLocked(now time.Time) {
- for key, entry := range l.attempts {
- if now.Sub(entry.lastSeen) > authLockoutPeriod+authFailureWindow {
- delete(l.attempts, key)
- }
- }
- for len(l.attempts) >= maxTrackedAuthKeys {
- var oldestKey string
- var oldest time.Time
- for key, entry := range l.attempts {
- if oldestKey == "" || entry.lastSeen.Before(oldest) {
- oldestKey = key
- oldest = entry.lastSeen
- }
- }
- if oldestKey == "" {
- return
- }
- delete(l.attempts, oldestKey)
- }
-}
-
-func authenticationAttemptKey(operator string) string {
- operator = strings.TrimSpace(operator)
- if operator == "" {
- return "anonymous"
- }
- if len(operator) > 128 {
- operator = operator[:128]
- }
- return operator
-}
diff --git a/betterdesk-support-agent/signalhost/auth_limiter_test.go b/betterdesk-support-agent/signalhost/auth_limiter_test.go
deleted file mode 100644
index e0c8cc0f..00000000
--- a/betterdesk-support-agent/signalhost/auth_limiter_test.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package signalhost
-
-import (
- "testing"
- "time"
-)
-
-func TestAuthenticationLimiterLocksRepeatedFailuresWithoutSecrets(t *testing.T) {
- now := time.Unix(1_700_000_000, 0)
- limiter := newAuthenticationLimiter(func() time.Time { return now })
-
- for i := 0; i < maxAuthFailures-1; i++ {
- if !limiter.allow("operator-1") {
- t.Fatalf("attempt %d was blocked before the threshold", i+1)
- }
- limiter.failure("operator-1")
- }
- if !limiter.allow("operator-1") {
- t.Fatal("last allowed attempt was blocked before recording its failure")
- }
- limiter.failure("operator-1")
- if limiter.allow("operator-1") {
- t.Fatal("repeated failed authentication did not lock the operator")
- }
- if !limiter.allow("another-operator") {
- t.Fatal("one operator's lockout affected another operator")
- }
-
- now = now.Add(authLockoutPeriod)
- if !limiter.allow("operator-1") {
- t.Fatal("operator remained locked after the lockout period")
- }
-}
-
-func TestAuthenticationLimiterSuccessClearsFailureHistory(t *testing.T) {
- now := time.Unix(1_700_000_000, 0)
- limiter := newAuthenticationLimiter(func() time.Time { return now })
-
- for i := 0; i < maxAuthFailures-1; i++ {
- limiter.failure("operator-1")
- }
- limiter.success("operator-1")
- for i := 0; i < maxAuthFailures-1; i++ {
- limiter.failure("operator-1")
- }
- if !limiter.allow("operator-1") {
- t.Fatal("a successful authentication did not reset failures")
- }
-}
-
-func TestAuthenticationAttemptKeyIsBounded(t *testing.T) {
- long := make([]byte, 256)
- for i := range long {
- long[i] = 'a'
- }
- if got := len(authenticationAttemptKey(string(long))); got != 128 {
- t.Fatalf("key length = %d, want 128", got)
- }
- if got := authenticationAttemptKey(" \t "); got != "anonymous" {
- t.Fatalf("blank key = %q, want anonymous", got)
- }
-}
diff --git a/betterdesk-support-agent/signalhost/authorization_test.go b/betterdesk-support-agent/signalhost/authorization_test.go
deleted file mode 100644
index 2dc0f30a..00000000
--- a/betterdesk-support-agent/signalhost/authorization_test.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package signalhost
-
-import "testing"
-
-func TestAuthorizeOperatorFailsClosedWithoutSupervisedConsent(t *testing.T) {
- host := New(Config{
- Unattended: func() bool { return false },
- })
- if host.authorizesOperator("operator") {
- t.Fatal("supervised relay must reject when no consent callback is available")
- }
-}
-
-func TestAuthorizeOperatorReevaluatesUnattendedPolicy(t *testing.T) {
- unattended := false
- host := New(Config{
- Unattended: func() bool { return unattended },
- Consent: func(string) bool { return false },
- })
- if host.authorizesOperator("operator") {
- t.Fatal("denied supervised policy must reject the operator")
- }
-
- unattended = true
- if !host.authorizesOperator("operator") {
- t.Fatal("current unattended policy should allow the operator")
- }
-}
diff --git a/betterdesk-support-agent/signalhost/capture.go b/betterdesk-support-agent/signalhost/capture.go
deleted file mode 100644
index 8d55d969..00000000
--- a/betterdesk-support-agent/signalhost/capture.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package signalhost
-
-// captureStrategy is one ffmpeg screen-capture input recipe.
-type captureStrategy struct {
- Name string
- Args []string // input args only (may include -f/-i); no encoder tail
-}
-
-// captureStrategies returns ordered capture attempts for this platform.
-// Platform files implement platformCaptureStrategies.
-func captureStrategies(fps int) []captureStrategy {
- return platformCaptureStrategies(clampStreamFPS(fps))
-}
diff --git a/betterdesk-support-agent/signalhost/capture_darwin.go b/betterdesk-support-agent/signalhost/capture_darwin.go
deleted file mode 100644
index f51114bc..00000000
--- a/betterdesk-support-agent/signalhost/capture_darwin.go
+++ /dev/null
@@ -1,16 +0,0 @@
-//go:build darwin
-
-package signalhost
-
-import "fmt"
-
-func platformCaptureStrategies(fps int) []captureStrategy {
- return []captureStrategy{{
- Name: "avfoundation",
- Args: []string{
- "-f", "avfoundation",
- "-framerate", fmt.Sprintf("%d", fps),
- "-i", "1:none",
- },
- }}
-}
diff --git a/betterdesk-support-agent/signalhost/capture_linux.go b/betterdesk-support-agent/signalhost/capture_linux.go
deleted file mode 100644
index 858f79f9..00000000
--- a/betterdesk-support-agent/signalhost/capture_linux.go
+++ /dev/null
@@ -1,24 +0,0 @@
-//go:build linux
-
-package signalhost
-
-import (
- "fmt"
- "os"
- "strings"
-)
-
-func platformCaptureStrategies(fps int) []captureStrategy {
- display := strings.TrimSpace(os.Getenv("DISPLAY"))
- if display == "" {
- return nil
- }
- return []captureStrategy{{
- Name: "x11grab",
- Args: []string{
- "-f", "x11grab",
- "-framerate", fmt.Sprintf("%d", fps),
- "-i", display,
- },
- }}
-}
diff --git a/betterdesk-support-agent/signalhost/capture_other.go b/betterdesk-support-agent/signalhost/capture_other.go
deleted file mode 100644
index 6b2f9f30..00000000
--- a/betterdesk-support-agent/signalhost/capture_other.go
+++ /dev/null
@@ -1,7 +0,0 @@
-//go:build !linux && !windows && !darwin
-
-package signalhost
-
-func platformCaptureStrategies(_ int) []captureStrategy {
- return nil
-}
diff --git a/betterdesk-support-agent/signalhost/capture_windows.go b/betterdesk-support-agent/signalhost/capture_windows.go
deleted file mode 100644
index a0024d62..00000000
--- a/betterdesk-support-agent/signalhost/capture_windows.go
+++ /dev/null
@@ -1,71 +0,0 @@
-//go:build windows
-
-package signalhost
-
-import (
- "context"
- "fmt"
- "os/exec"
- "strings"
- "sync"
- "time"
-)
-
-func platformCaptureStrategies(fps int) []captureStrategy {
- // gdigrab's "desktop" input covers the virtual desktop. Prefer it over
- // ddagrab, which is restricted to one physical output and otherwise makes
- // multi-monitor users look as if only a fragment of their screen is shared.
- out := []captureStrategy{{
- Name: "gdigrab",
- Args: []string{
- "-f", "gdigrab",
- "-framerate", fmt.Sprintf("%d", fps),
- "-i", "desktop",
- },
- }}
- if ffmpegSupportsDDAGrab() {
- out = append(out, captureStrategy{
- Name: "ddagrab(single-output fallback)",
- Args: []string{
- "-f", "lavfi",
- "-i", fmt.Sprintf("ddagrab=output_idx=0:framerate=%d:draw_mouse=1,hwdownload,format=bgra", fps),
- },
- })
- }
- return out
-}
-
-var (
- ddagrabProbeOnce sync.Once
- ddagrabAvailable bool
-)
-
-func ffmpegSupportsDDAGrab() bool {
- ddagrabProbeOnce.Do(func() {
- path, err := exec.LookPath("ffmpeg")
- if err != nil {
- return
- }
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
- cmd := exec.CommandContext(ctx, path, "-hide_banner", "-filters")
- hideConsole(cmd)
- out, err := cmd.CombinedOutput()
- if err != nil || ctx.Err() != nil {
- return
- }
- ddagrabAvailable = filterListed(out, "ddagrab") &&
- filterListed(out, "hwdownload") &&
- filterListed(out, "format")
- })
- return ddagrabAvailable
-}
-
-func filterListed(output []byte, filter string) bool {
- for _, field := range strings.Fields(string(output)) {
- if field == filter {
- return true
- }
- }
- return false
-}
diff --git a/betterdesk-support-agent/signalhost/codec_negotiation.go b/betterdesk-support-agent/signalhost/codec_negotiation.go
deleted file mode 100644
index 9cd6fce2..00000000
--- a/betterdesk-support-agent/signalhost/codec_negotiation.go
+++ /dev/null
@@ -1,461 +0,0 @@
-package signalhost
-
-import (
- "sync"
- "time"
-
- pb "github.com/unitronix/betterdesk-server/proto"
-)
-
-type negotiatedVideoCodec uint8
-
-const (
- videoCodecNone negotiatedVideoCodec = iota
- videoCodecH264
- videoCodecH265
- videoCodecVP8
- videoCodecVP9
- videoCodecAV1
-)
-
-const (
- defaultStreamFPS = 15
- minStreamFPS = 2
- maxStreamFPS = 30
- defaultStreamQuality = 65
- minStreamQuality = 30
- maxStreamQuality = 90
-
- encoderReconfigureInterval = 2 * time.Second
- encoderRecoveryInterval = 6 * time.Second
- healthyWriteSamples = 12
-)
-
-type videoSettings struct {
- fps int
- quality int
-}
-
-// streamState holds the negotiated H.264 settings for one relay session.
-// FFmpeg cannot safely reconfigure an Annex-B encoder in place, so a bounded
-// restart is used to apply a change and ensure the next frame is a real IDR.
-type streamState struct {
- mu sync.Mutex
-
- codec negotiatedVideoCodec
-
- targetFPS int
- targetQuality int
- fps int
- quality int
-
- lastRestart time.Time
- healthyWrites int
- reconfigure chan struct{}
-}
-
-func newStreamState(codec negotiatedVideoCodec, options *pb.OptionMessage) *streamState {
- settings := requestedVideoSettings(options)
- return &streamState{
- codec: codec,
- targetFPS: settings.fps,
- targetQuality: settings.quality,
- fps: settings.fps,
- quality: settings.quality,
- reconfigure: make(chan struct{}, 1),
- }
-}
-
-func requestedVideoSettings(options *pb.OptionMessage) videoSettings {
- settings := videoSettings{
- fps: defaultStreamFPS,
- quality: defaultStreamQuality,
- }
- if quality, ok := requestedQuality(options); ok {
- settings.quality = quality
- }
- if fps, ok := requestedFPS(options); ok {
- settings.fps = fps
- }
- return settings
-}
-
-func requestedQuality(options *pb.OptionMessage) (int, bool) {
- if options == nil {
- return 0, false
- }
- if custom := options.GetCustomImageQuality(); custom > 0 {
- return clampStreamQuality(int(custom)), true
- }
- switch options.GetImageQuality() {
- case pb.ImageQuality_Low:
- return 40, true
- case pb.ImageQuality_Balanced:
- return defaultStreamQuality, true
- case pb.ImageQuality_Best:
- return 85, true
- default:
- return 0, false
- }
-}
-
-func requestedFPS(options *pb.OptionMessage) (int, bool) {
- if options == nil || options.GetCustomFps() <= 0 {
- return 0, false
- }
- return clampStreamFPS(int(options.GetCustomFps())), true
-}
-
-func clampStreamQuality(quality int) int {
- if quality < minStreamQuality {
- return minStreamQuality
- }
- if quality > maxStreamQuality {
- return maxStreamQuality
- }
- return quality
-}
-
-func clampStreamFPS(fps int) int {
- if fps < minStreamFPS {
- return minStreamFPS
- }
- if fps > maxStreamFPS {
- return maxStreamFPS
- }
- return fps
-}
-
-// h264CRF maps the negotiated 0-100 quality scale to libx264/libx265 CRF.
-func h264CRF(quality int) int {
- quality = clampStreamQuality(quality)
- return 42 - quality*28/100
-}
-
-func frameInterval(fps int) time.Duration {
- return time.Second / time.Duration(clampStreamFPS(fps))
-}
-
-func (c negotiatedVideoCodec) wire() string {
- switch c {
- case videoCodecH264:
- return wireH264
- case videoCodecH265:
- return wireH265
- case videoCodecVP8:
- return wireVP8
- case videoCodecVP9:
- return wireVP9
- case videoCodecAV1:
- return wireAV1
- default:
- return wireNone
- }
-}
-
-func (s *streamState) settings() videoSettings {
- s.mu.Lock()
- defer s.mu.Unlock()
- return videoSettings{fps: s.fps, quality: s.quality}
-}
-
-func (s *streamState) markEncoderStarted(now time.Time) {
- s.mu.Lock()
- s.lastRestart = now
- s.healthyWrites = 0
- s.mu.Unlock()
-}
-
-func (s *streamState) applyPeerOptions(options *pb.OptionMessage, now time.Time) bool {
- quality, hasQuality := requestedQuality(options)
- fps, hasFPS := requestedFPS(options)
- var nextCodec negotiatedVideoCodec
- hasCodec := false
- if sd := options.GetSupportedDecoding(); sd != nil {
- nextCodec = negotiateVideoCodec(advertisedVideoEncoding(), sd)
- hasCodec = nextCodec != videoCodecNone
- }
- if !hasQuality && !hasFPS && !hasCodec {
- return false
- }
-
- s.mu.Lock()
- if !s.canReconfigureLocked(now) {
- s.mu.Unlock()
- return false
- }
-
- next := videoSettings{fps: s.fps, quality: s.quality}
- targetFPS, targetQuality := s.targetFPS, s.targetQuality
- changed := false
- if hasFPS {
- next.fps = fps
- targetFPS = fps
- }
- if hasQuality {
- next.quality = quality
- targetQuality = quality
- }
- if hasCodec && nextCodec != s.codec {
- s.codec = nextCodec
- changed = true
- }
- if next.fps != s.fps || next.quality != s.quality ||
- targetFPS != s.targetFPS || targetQuality != s.targetQuality {
- s.fps = next.fps
- s.quality = next.quality
- s.targetFPS = targetFPS
- s.targetQuality = targetQuality
- changed = true
- }
- if !changed {
- s.mu.Unlock()
- return false
- }
-
- s.lastRestart = now
- s.healthyWrites = 0
- s.mu.Unlock()
- s.requestReconfigure()
- return true
-}
-
-func (s *streamState) currentCodec() negotiatedVideoCodec {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.codec
-}
-
-// requestKeyframe starts a fresh H.264 encoder rather than marking an arbitrary
-// delta frame as key. The restart rate limit prevents a peer from using refresh
-// requests to repeatedly spawn encoders.
-func (s *streamState) requestKeyframe(now time.Time) bool {
- s.mu.Lock()
- if !s.canReconfigureLocked(now) {
- s.mu.Unlock()
- return false
- }
- s.lastRestart = now
- s.healthyWrites = 0
- s.mu.Unlock()
- s.requestReconfigure()
- return true
-}
-
-// observeWrite feeds transport backpressure into a bounded controller. It
-// changes FPS and quality only by restarting the local encoder, which keeps the
-// H.264 stream decodable instead of dropping inter-frame deltas.
-func (s *streamState) observeWrite(elapsed time.Duration) {
- if s.adjustForWrite(elapsed, time.Now()) {
- s.requestReconfigure()
- }
-}
-
-func (s *streamState) adjustForWrite(elapsed time.Duration, now time.Time) bool {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- interval := frameInterval(s.fps)
- slowThreshold := 3 * interval
- if slowThreshold < 200*time.Millisecond {
- slowThreshold = 200 * time.Millisecond
- }
- if elapsed >= slowThreshold {
- if !s.canReconfigureLocked(now) {
- return false
- }
- nextFPS := s.fps - 2
- if nextFPS < minStreamFPS {
- nextFPS = minStreamFPS
- }
- nextQuality := s.quality - 8
- if nextQuality < minStreamQuality {
- nextQuality = minStreamQuality
- }
- if nextFPS == s.fps && nextQuality == s.quality {
- s.healthyWrites = 0
- return false
- }
- s.fps = nextFPS
- s.quality = nextQuality
- s.lastRestart = now
- s.healthyWrites = 0
- return true
- }
-
- if elapsed <= interval/4 {
- s.healthyWrites++
- if s.healthyWrites < healthyWriteSamples ||
- now.Sub(s.lastRestart) < encoderRecoveryInterval ||
- !s.canReconfigureLocked(now) {
- return false
- }
-
- nextFPS := s.fps + 1
- if nextFPS > s.targetFPS {
- nextFPS = s.targetFPS
- }
- nextQuality := s.quality + 4
- if nextQuality > s.targetQuality {
- nextQuality = s.targetQuality
- }
- if nextFPS == s.fps && nextQuality == s.quality {
- return false
- }
- s.fps = nextFPS
- s.quality = nextQuality
- s.lastRestart = now
- s.healthyWrites = 0
- return true
- }
-
- s.healthyWrites = 0
- return false
-}
-
-func (s *streamState) canReconfigureLocked(now time.Time) bool {
- return s.lastRestart.IsZero() || now.Sub(s.lastRestart) >= encoderReconfigureInterval
-}
-
-func (s *streamState) requestReconfigure() {
- select {
- case s.reconfigure <- struct{}{}:
- default:
- }
-}
-
-func supportedEncodingFromCaps(caps encodeCaps) *pb.SupportedEncoding {
- if !caps.h264 && !caps.h265 && !caps.vp8 && !caps.av1 {
- // VP9 is not a field on SupportedEncoding in the RustDesk schema;
- // hosts still send vp9s when negotiated. If only VP9 works, advertise
- // a non-nil encoding so PeerInfo stays valid and negotiation uses
- // local VP9 capability + peer AbilityVp9.
- if !caps.vp9 {
- return nil
- }
- return &pb.SupportedEncoding{}
- }
- return &pb.SupportedEncoding{
- H264: caps.h264,
- H265: caps.h265,
- Vp8: caps.vp8,
- Av1: caps.av1,
- }
-}
-
-type encodeCaps struct {
- h264 bool
- h265 bool
- vp8 bool
- vp9 bool
- av1 bool
-}
-
-func probeEncodeCaps() encodeCaps {
- return encodeCaps{
- h264: canEncodeWire(wireH264),
- h265: canEncodeWire(wireH265),
- vp8: canEncodeWire(wireVP8),
- vp9: canEncodeWire(wireVP9),
- av1: canEncodeWire(wireAV1),
- }
-}
-
-var (
- advertisedCapsOnce sync.Once
- advertisedCapsValue encodeCaps
- advertisedCapsOverride *encodeCaps // tests only
-)
-
-func localEncodeCaps() encodeCaps {
- if advertisedCapsOverride != nil {
- return *advertisedCapsOverride
- }
- advertisedCapsOnce.Do(func() {
- advertisedCapsValue = probeEncodeCaps()
- })
- return advertisedCapsValue
-}
-
-func setEncodeCapsForTest(caps encodeCaps) func() {
- prev := advertisedCapsOverride
- cp := caps
- advertisedCapsOverride = &cp
- return func() { advertisedCapsOverride = prev }
-}
-
-func advertisedVideoEncoding() *pb.SupportedEncoding {
- return supportedEncodingFromCaps(localEncodeCaps())
-}
-
-// negotiateVideoCodec picks a mutually supported codec using PreferCodec.
-// Auto order matches RdClient efficiency preference: AV1 → VP9 → H264 → VP8 → H265.
-func negotiateVideoCodec(local *pb.SupportedEncoding, peer *pb.SupportedDecoding) negotiatedVideoCodec {
- return negotiateVideoCodecCaps(localEncodeCaps(), local, peer)
-}
-
-func negotiateVideoCodecCaps(caps encodeCaps, local *pb.SupportedEncoding, peer *pb.SupportedDecoding) negotiatedVideoCodec {
- if peer == nil {
- return videoCodecNone
- }
- if local == nil && !caps.vp9 && !caps.h264 && !caps.h265 && !caps.vp8 && !caps.av1 {
- return videoCodecNone
- }
-
- can := func(c negotiatedVideoCodec) bool {
- switch c {
- case videoCodecH264:
- return caps.h264 && peer.GetAbilityH264() > 0
- case videoCodecH265:
- return caps.h265 && peer.GetAbilityH265() > 0
- case videoCodecVP8:
- return caps.vp8 && peer.GetAbilityVp8() > 0
- case videoCodecVP9:
- return caps.vp9 && peer.GetAbilityVp9() > 0
- case videoCodecAV1:
- return caps.av1 && peer.GetAbilityAv1() > 0
- default:
- return false
- }
- }
-
- switch peer.GetPrefer() {
- case pb.SupportedDecoding_VP9:
- if can(videoCodecVP9) {
- return videoCodecVP9
- }
- case pb.SupportedDecoding_H264:
- if can(videoCodecH264) {
- return videoCodecH264
- }
- case pb.SupportedDecoding_H265:
- if can(videoCodecH265) {
- return videoCodecH265
- }
- case pb.SupportedDecoding_VP8:
- if can(videoCodecVP8) {
- return videoCodecVP8
- }
- case pb.SupportedDecoding_AV1:
- if can(videoCodecAV1) {
- return videoCodecAV1
- }
- }
-
- for _, c := range []negotiatedVideoCodec{
- videoCodecAV1, videoCodecVP9, videoCodecH264, videoCodecVP8, videoCodecH265,
- } {
- if can(c) {
- return c
- }
- }
- return videoCodecNone
-}
-
-func (codec negotiatedVideoCodec) String() string {
- w := codec.wire()
- if w == wireNone {
- return "none"
- }
- return w
-}
diff --git a/betterdesk-support-agent/signalhost/codec_negotiation_test.go b/betterdesk-support-agent/signalhost/codec_negotiation_test.go
deleted file mode 100644
index e01893b3..00000000
--- a/betterdesk-support-agent/signalhost/codec_negotiation_test.go
+++ /dev/null
@@ -1,238 +0,0 @@
-package signalhost
-
-import (
- "testing"
- "time"
-
- pb "github.com/unitronix/betterdesk-server/proto"
-)
-
-func TestSupportedEncodingFromCaps(t *testing.T) {
- if got := supportedEncodingFromCaps(encodeCaps{}); got != nil {
- t.Fatalf("empty caps advertised as %#v", got)
- }
- if got := supportedEncodingFromCaps(encodeCaps{vp9: true}); got == nil {
- t.Fatal("vp9-only should still produce non-nil encoding")
- }
- got := supportedEncodingFromCaps(encodeCaps{h264: true, av1: true, h265: true, vp8: true})
- if got == nil || !got.GetH264() || !got.GetAv1() || !got.GetH265() || !got.GetVp8() {
- t.Fatalf("encoding = %#v", got)
- }
-}
-
-func TestNegotiateVideoCodecPreferAndAuto(t *testing.T) {
- caps := encodeCaps{h264: true, vp9: true, av1: true, vp8: true, h265: true}
- local := supportedEncodingFromCaps(caps)
-
- tests := []struct {
- name string
- peer *pb.SupportedDecoding
- want negotiatedVideoCodec
- }{
- {
- name: "missing peer",
- peer: nil,
- want: videoCodecNone,
- },
- {
- name: "prefer h264",
- peer: &pb.SupportedDecoding{AbilityH264: 1, AbilityVp9: 1, Prefer: pb.SupportedDecoding_H264},
- want: videoCodecH264,
- },
- {
- name: "prefer vp9",
- peer: &pb.SupportedDecoding{AbilityH264: 1, AbilityVp9: 1, Prefer: pb.SupportedDecoding_VP9},
- want: videoCodecVP9,
- },
- {
- name: "prefer av1",
- peer: &pb.SupportedDecoding{AbilityAv1: 1, AbilityH264: 1, Prefer: pb.SupportedDecoding_AV1},
- want: videoCodecAV1,
- },
- {
- name: "prefer vp8",
- peer: &pb.SupportedDecoding{AbilityVp8: 1, AbilityH264: 1, Prefer: pb.SupportedDecoding_VP8},
- want: videoCodecVP8,
- },
- {
- name: "prefer h265",
- peer: &pb.SupportedDecoding{AbilityH265: 1, AbilityH264: 1, Prefer: pb.SupportedDecoding_H265},
- want: videoCodecH265,
- },
- {
- name: "auto prefers av1",
- peer: &pb.SupportedDecoding{
- AbilityAv1: 1, AbilityVp9: 1, AbilityH264: 1, AbilityVp8: 1, AbilityH265: 1,
- Prefer: pb.SupportedDecoding_Auto,
- },
- want: videoCodecAV1,
- },
- {
- name: "prefer unavailable falls back",
- peer: &pb.SupportedDecoding{AbilityH264: 1, Prefer: pb.SupportedDecoding_AV1},
- want: videoCodecH264,
- },
- {
- name: "preference without ability",
- peer: &pb.SupportedDecoding{Prefer: pb.SupportedDecoding_H264},
- want: videoCodecNone,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- if got := negotiateVideoCodecCaps(caps, local, tc.peer); got != tc.want {
- t.Fatalf("got %s, want %s", got, tc.want)
- }
- })
- }
-}
-
-func TestNegotiateRespectsLocalCaps(t *testing.T) {
- caps := encodeCaps{h264: true}
- local := supportedEncodingFromCaps(caps)
- peer := &pb.SupportedDecoding{AbilityH264: 1, AbilityAv1: 1, Prefer: pb.SupportedDecoding_AV1}
- if got := negotiateVideoCodecCaps(caps, local, peer); got != videoCodecH264 {
- t.Fatalf("got %s, want h264", got)
- }
-}
-
-func TestRequestedVideoSettingsAreBounded(t *testing.T) {
- got := requestedVideoSettings(&pb.OptionMessage{
- CustomImageQuality: 1000,
- CustomFps: 1000,
- })
- if got.quality != maxStreamQuality || got.fps != maxStreamFPS {
- t.Fatalf("unbounded request resolved to %#v", got)
- }
-
- got = requestedVideoSettings(&pb.OptionMessage{
- CustomImageQuality: 1,
- CustomFps: 1,
- })
- if got.quality != minStreamQuality || got.fps != minStreamFPS {
- t.Fatalf("low request resolved to %#v", got)
- }
-
- got = requestedVideoSettings(&pb.OptionMessage{
- CustomImageQuality: -1,
- CustomFps: -1,
- ImageQuality: pb.ImageQuality_Low,
- })
- if got.quality != 40 || got.fps != defaultStreamFPS {
- t.Fatalf("invalid request resolved to %#v", got)
- }
-}
-
-func TestPeerOptionChangesAreRateLimitedAndBounded(t *testing.T) {
- st := newStreamState(videoCodecH264, &pb.OptionMessage{
- CustomImageQuality: 70,
- CustomFps: 20,
- })
- start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
- st.markEncoderStarted(start)
-
- if st.applyPeerOptions(&pb.OptionMessage{
- CustomImageQuality: 1000,
- CustomFps: 1000,
- }, start.Add(time.Second)) {
- t.Fatal("settings changed inside the encoder restart cooldown")
- }
- if got := st.settings(); got.quality != 70 || got.fps != 20 {
- t.Fatalf("cooldown changed settings to %#v", got)
- }
-
- if !st.applyPeerOptions(&pb.OptionMessage{
- CustomImageQuality: 1000,
- CustomFps: 1000,
- }, start.Add(encoderReconfigureInterval)) {
- t.Fatal("bounded settings update was not applied")
- }
- if got := st.settings(); got.quality != maxStreamQuality || got.fps != maxStreamFPS {
- t.Fatalf("bounded settings update = %#v", got)
- }
-}
-
-func TestPeerCodecPreferenceRestartsEncoder(t *testing.T) {
- restore := setEncodeCapsForTest(encodeCaps{h264: true, vp9: true})
- defer restore()
-
- st := newStreamState(videoCodecH264, nil)
- start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
- st.markEncoderStarted(start.Add(-encoderReconfigureInterval))
-
- if !st.applyPeerOptions(&pb.OptionMessage{
- SupportedDecoding: &pb.SupportedDecoding{
- AbilityH264: 1,
- AbilityVp9: 1,
- Prefer: pb.SupportedDecoding_VP9,
- },
- }, start) {
- t.Fatal("codec preference did not trigger reconfigure")
- }
- if st.currentCodec() != videoCodecVP9 {
- t.Fatalf("codec = %s, want vp9", st.currentCodec())
- }
-}
-
-func TestCongestionControllerStaysWithinNegotiatedLimits(t *testing.T) {
- st := newStreamState(videoCodecH264, &pb.OptionMessage{
- CustomImageQuality: 90,
- CustomFps: 30,
- })
- now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
- st.markEncoderStarted(now.Add(-encoderReconfigureInterval))
-
- for i := 0; i < 20; i++ {
- now = now.Add(encoderReconfigureInterval)
- st.adjustForWrite(3*time.Second, now)
- }
- if got := st.settings(); got.quality != minStreamQuality || got.fps != minStreamFPS {
- t.Fatalf("congestion floor = %#v, want quality=%d fps=%d", got, minStreamQuality, minStreamFPS)
- }
-
- now = now.Add(encoderRecoveryInterval)
- for i := 0; i < healthyWriteSamples; i++ {
- now = now.Add(time.Millisecond)
- st.adjustForWrite(time.Millisecond, now)
- }
- got := st.settings()
- if got.quality <= minStreamQuality || got.fps <= minStreamFPS {
- t.Fatalf("healthy transport did not recover: %#v", got)
- }
- if got.quality > maxStreamQuality || got.fps > maxStreamFPS {
- t.Fatalf("recovery exceeded negotiated limits: %#v", got)
- }
-}
-
-func TestH264HasIDRDoesNotMislabelDeltaFrames(t *testing.T) {
- idr := []byte{0, 0, 0, 1, 0x67, 0, 0, 1, 0x65, 1}
- if !h264HasIDR(idr) {
- t.Fatal("IDR access unit was not recognized")
- }
-
- delta := []byte{0, 0, 0, 1, 0x67, 0, 0, 1, 0x41, 1}
- if h264HasIDR(delta) {
- t.Fatal("delta access unit was incorrectly marked as key")
- }
-}
-
-func TestBuildCaptureEncodeArgs(t *testing.T) {
- plan := encoderPlan{wire: wireH264, ffmpegName: "libx264", hwAccel: hwNone, mode: frameModeAnnexB}
- args := buildCaptureEncodeArgs(captureStrategy{
- Name: "gdigrab",
- Args: []string{"-f", "gdigrab", "-framerate", "15", "-i", "desktop"},
- }, plan, 15, 65)
- if len(args) < 8 {
- t.Fatalf("args too short: %#v", args)
- }
- joined := false
- for _, a := range args {
- if a == "libx264" {
- joined = true
- }
- }
- if !joined {
- t.Fatalf("missing encoder in %#v", args)
- }
-}
diff --git a/betterdesk-support-agent/signalhost/crypto.go b/betterdesk-support-agent/signalhost/crypto.go
deleted file mode 100644
index e6da4a70..00000000
--- a/betterdesk-support-agent/signalhost/crypto.go
+++ /dev/null
@@ -1,12 +0,0 @@
-package signalhost
-
-import (
- "crypto/sha256"
-)
-
-// hashPassword matches RustDesk / betterdesk-mgmt login hashing on the wire.
-// SHA-256 is required by the RustDesk protocol (not password storage).
-func hashPassword(password, salt, challenge string) [32]byte {
- step1 := sha256.Sum256(append(append([]byte{}, password...), salt...))
- return sha256.Sum256(append(step1[:], challenge...))
-}
diff --git a/betterdesk-support-agent/signalhost/crypto_test.go b/betterdesk-support-agent/signalhost/crypto_test.go
deleted file mode 100644
index f2d1ee8d..00000000
--- a/betterdesk-support-agent/signalhost/crypto_test.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package signalhost
-
-import (
- "crypto/rand"
- "testing"
-
- "golang.org/x/crypto/nacl/box"
-)
-
-func TestSecretBoxRoundTrip(t *testing.T) {
- var key [32]byte
- copy(key[:], []byte("01234567890123456789012345678901"))
- a := newSecretBoxStream(key)
- b := newSecretBoxStream(key)
-
- plain := []byte("RustDesk peer frame payload")
- ct, err := a.encrypt(plain)
- if err != nil {
- t.Fatal(err)
- }
- out, err := b.decrypt(ct)
- if err != nil {
- t.Fatal(err)
- }
- if string(out) != string(plain) {
- t.Fatalf("round trip mismatch")
- }
-}
-
-func TestOpenPublicKeyRoundTrip(t *testing.T) {
- target, err := generateEphemeralKeyPair()
- if err != nil {
- t.Fatal(err)
- }
- initPub, initPriv, err := box.GenerateKey(rand.Reader)
- if err != nil {
- t.Fatal(err)
- }
-
- var sym [32]byte
- copy(sym[:], []byte("abcdefghijklmnopqrstuvwxyz123456"))
-
- var nonce [24]byte
- sealed := box.Seal(nil, sym[:], &nonce, &target.public, initPriv)
-
- theirPK := initPub[:]
- got, err := openPublicKey(target, theirPK, sealed)
- if err != nil {
- t.Fatal(err)
- }
- if got != sym {
- t.Fatalf("symmetric key mismatch")
- }
-}
-
-func TestHashPasswordDeterministic(t *testing.T) {
- a := hashPassword("secret", "salt", "challenge")
- b := hashPassword("secret", "salt", "challenge")
- if a != b {
- t.Fatal("hash not deterministic")
- }
-}
-
-func TestPeerHeaderLargeFrame(t *testing.T) {
- payload := make([]byte, 200*1024)
- hdr := encodePeerHeader(len(payload))
- if len(hdr) < 2 {
- t.Fatal("expected multi-byte header for 200KB")
- }
-}
diff --git a/betterdesk-support-agent/signalhost/display.go b/betterdesk-support-agent/signalhost/display.go
deleted file mode 100644
index 7041cf3d..00000000
--- a/betterdesk-support-agent/signalhost/display.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package signalhost
-
-import (
- "os"
- "os/user"
- "runtime"
-
- bdagent "github.com/unitronix/betterdesk-agent/agent"
- pb "github.com/unitronix/betterdesk-server/proto"
-)
-
-// buildPeerInfo publishes only codecs that were actively probed for this host.
-// Features such as audio, file transfer, clipboard, terminal, and multi-monitor
-// remain absent until their corresponding host paths exist.
-func buildPeerInfo(deviceID string, encoding *pb.SupportedEncoding) (*pb.PeerInfo, uint32, uint32) {
- displays, w, h := primaryDisplayInfo()
- username := "user"
- if u, err := user.Current(); err == nil && u.Username != "" {
- username = u.Username
- }
- hostname := deviceID
- if hn, err := os.Hostname(); err == nil && hn != "" {
- hostname = hn
- }
- return &pb.PeerInfo{
- Username: username,
- Hostname: hostname,
- Platform: runtime.GOOS,
- Displays: displays,
- CurrentDisplay: 0,
- Version: "1.0",
- Encoding: encoding,
- }, w, h
-}
-
-func primaryDisplayInfo() ([]*pb.DisplayInfo, uint32, uint32) {
- w, h := uint32(1920), uint32(1080)
- if jpeg, err := bdagent.CaptureScreenshotJPEG(); err == nil && len(jpeg) > 0 {
- if jw, jh := jpegSize(jpeg); jw > 0 && jh > 0 {
- w, h = uint32(jw), uint32(jh)
- }
- }
- display := &pb.DisplayInfo{
- X: 0, Y: 0,
- Width: int32(w), Height: int32(h),
- Name: "Primary", Online: true,
- }
- return []*pb.DisplayInfo{display}, w, h
-}
-
-// jpegSize reads width/height from JPEG SOF marker.
-func jpegSize(data []byte) (int, int) {
- if len(data) < 4 || data[0] != 0xFF || data[1] != 0xD8 {
- return 0, 0
- }
- i := 2
- for i+9 < len(data) {
- if data[i] != 0xFF {
- i++
- continue
- }
- marker := data[i+1]
- if marker == 0xC0 || marker == 0xC2 {
- h := int(data[i+5])<<8 | int(data[i+6])
- w := int(data[i+7])<<8 | int(data[i+8])
- return w, h
- }
- if i+3 >= len(data) {
- break
- }
- segLen := int(data[i+2])<<8 | int(data[i+3])
- if segLen < 2 {
- break
- }
- i += 2 + segLen
- }
- return 0, 0
-}
diff --git a/betterdesk-support-agent/signalhost/encoder_probe.go b/betterdesk-support-agent/signalhost/encoder_probe.go
deleted file mode 100644
index 9a234760..00000000
--- a/betterdesk-support-agent/signalhost/encoder_probe.go
+++ /dev/null
@@ -1,193 +0,0 @@
-package signalhost
-
-import (
- "context"
- "io"
- "os"
- "os/exec"
- "strings"
- "sync"
- "time"
-)
-
-// Wire codec identifiers (RustDesk PreferCodec / VideoFrame unions).
-const (
- wireNone = ""
- wireH264 = "h264"
- wireH265 = "h265"
- wireVP8 = "vp8"
- wireVP9 = "vp9"
- wireAV1 = "av1"
-)
-
-const (
- hwNone = "none"
- hwVAAPI = "vaapi"
- hwNVENC = "nvenc"
- hwQSV = "qsv"
- hwAMF = "amf"
- hwVideoToolbox = "videotoolbox"
-)
-
-type frameMode int
-
-const (
- frameModeAnnexB frameMode = iota
- frameModeIVF
-)
-
-// encoderPlan is the concrete ffmpeg encoder for one session codec.
-type encoderPlan struct {
- wire string
- ffmpegName string
- hwAccel string
- mode frameMode
-}
-
-var encoderCandidates = map[string][]string{
- wireH264: {"h264_nvenc", "h264_qsv", "h264_vaapi", "h264_amf", "h264_videotoolbox", "libx264"},
- wireH265: {"hevc_nvenc", "hevc_qsv", "hevc_vaapi", "hevc_amf", "hevc_videotoolbox", "libx265"},
- wireVP9: {"vp9_vaapi", "vp9_qsv", "libvpx-vp9"},
- wireVP8: {"libvpx"},
- wireAV1: {"av1_nvenc", "av1_qsv", "av1_vaapi", "av1_amf", "libsvtav1", "libaom-av1"},
-}
-
-func hwOfEncoder(name string) string {
- switch {
- case strings.HasSuffix(name, "_nvenc"):
- return hwNVENC
- case strings.HasSuffix(name, "_qsv"):
- return hwQSV
- case strings.HasSuffix(name, "_vaapi"):
- return hwVAAPI
- case strings.HasSuffix(name, "_amf"):
- return hwAMF
- case strings.HasSuffix(name, "_videotoolbox"):
- return hwVideoToolbox
- default:
- return hwNone
- }
-}
-
-type encoderProbe struct {
- once sync.Once
- available map[string]bool
- working sync.Map
- ffmpeg string
-}
-
-var globalEncoderProbe = &encoderProbe{available: map[string]bool{}}
-
-func (p *encoderProbe) load() {
- p.once.Do(func() {
- ffmpeg, err := exec.LookPath("ffmpeg")
- if err != nil {
- return
- }
- p.ffmpeg = ffmpeg
- ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
- defer cancel()
- cmd := exec.CommandContext(ctx, ffmpeg, "-hide_banner", "-encoders")
- hideConsole(cmd)
- out, err := cmd.Output()
- if err != nil {
- return
- }
- for _, line := range strings.Split(string(out), "\n") {
- f := strings.Fields(strings.TrimSpace(line))
- if len(f) >= 2 && strings.HasPrefix(f[0], "V") {
- p.available[f[1]] = true
- }
- }
- })
-}
-
-func (p *encoderProbe) validate(name string) bool {
- p.load()
- if p.ffmpeg == "" || !p.available[name] {
- return false
- }
- if hwOfEncoder(name) == hwNone {
- return true
- }
- if v, ok := p.working.Load(name); ok {
- return v.(bool)
- }
- ok := p.testEncode(name)
- p.working.Store(name, ok)
- return ok
-}
-
-func (p *encoderProbe) testEncode(name string) bool {
- ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
- defer cancel()
- plan := encoderPlan{ffmpegName: name, hwAccel: hwOfEncoder(name)}
- args := []string{"-hide_banner", "-loglevel", "error"}
- args = append(args, hwTestPreInput(plan)...)
- args = append(args, "-f", "lavfi", "-i", "color=c=black:s=64x64:r=5", "-frames:v", "1")
- args = append(args, hwTestFilter(plan)...)
- args = append(args, "-c:v", name, "-f", "null", "-")
- cmd := exec.CommandContext(ctx, p.ffmpeg, args...)
- hideConsole(cmd)
- cmd.Stdout = io.Discard
- cmd.Stderr = io.Discard
- return cmd.Run() == nil
-}
-
-func hwTestPreInput(plan encoderPlan) []string {
- switch plan.hwAccel {
- case hwVAAPI:
- return []string{"-init_hw_device", "vaapi=va:" + vaapiDevice(), "-filter_hw_device", "va"}
- case hwQSV:
- return []string{"-init_hw_device", "qsv=qsv", "-filter_hw_device", "qsv"}
- default:
- return nil
- }
-}
-
-func hwTestFilter(plan encoderPlan) []string {
- switch plan.hwAccel {
- case hwVAAPI:
- return []string{"-vf", "format=nv12,hwupload"}
- case hwQSV:
- return []string{"-vf", "format=nv12,hwupload=extra_hw_frames=4"}
- default:
- return nil
- }
-}
-
-func vaapiDevice() string {
- if d := strings.TrimSpace(os.Getenv("BETTERDESK_VAAPI_DEVICE")); d != "" {
- return d
- }
- return "/dev/dri/renderD128"
-}
-
-// resolveEncoderPlan picks the best working ffmpeg encoder for a wire codec.
-func resolveEncoderPlan(wire string) (encoderPlan, bool) {
- cands := encoderCandidates[wire]
- if len(cands) == 0 {
- return encoderPlan{}, false
- }
- for _, name := range cands {
- if !globalEncoderProbe.validate(name) {
- continue
- }
- mode := frameModeAnnexB
- if wire == wireVP9 || wire == wireVP8 || wire == wireAV1 {
- mode = frameModeIVF
- }
- return encoderPlan{
- wire: wire,
- ffmpegName: name,
- hwAccel: hwOfEncoder(name),
- mode: mode,
- }, true
- }
- return encoderPlan{}, false
-}
-
-func canEncodeWire(wire string) bool {
- _, ok := resolveEncoderPlan(wire)
- return ok
-}
diff --git a/betterdesk-support-agent/signalhost/encoder_tail.go b/betterdesk-support-agent/signalhost/encoder_tail.go
deleted file mode 100644
index 0c8e2406..00000000
--- a/betterdesk-support-agent/signalhost/encoder_tail.go
+++ /dev/null
@@ -1,133 +0,0 @@
-package signalhost
-
-import "strconv"
-
-func (plan encoderPlan) preInputArgs() []string {
- switch plan.hwAccel {
- case hwVAAPI:
- return []string{"-init_hw_device", "vaapi=va:" + vaapiDevice(), "-filter_hw_device", "va"}
- case hwQSV:
- return []string{"-init_hw_device", "qsv=qsv", "-filter_hw_device", "qsv"}
- default:
- return nil
- }
-}
-
-func (plan encoderPlan) encoderTail(fps, quality int) []string {
- quality = clampStreamQuality(quality)
- fps = clampStreamFPS(fps)
- switch plan.mode {
- case frameModeIVF:
- return plan.ivfTail(fps, quality)
- default:
- return plan.annexBTail(fps, quality)
- }
-}
-
-func (plan encoderPlan) annexBTail(fps, quality int) []string {
- gop := strconv.Itoa(fps * 2)
- out := []string{}
- switch plan.hwAccel {
- case hwVAAPI:
- out = append(out, "-vf", "format=nv12,hwupload")
- case hwQSV:
- out = append(out, "-vf", "format=nv12,hwupload=extra_hw_frames=8")
- }
- out = append(out, "-c:v", plan.ffmpegName)
- switch plan.hwAccel {
- case hwNVENC:
- out = append(out, "-preset", "p4", "-tune", "ll", "-rc", "vbr", "-cq", strconv.Itoa(qToCQ(quality)))
- case hwVAAPI:
- out = append(out, "-rc_mode", "CQP", "-qp", strconv.Itoa(qToQP(quality)))
- case hwQSV:
- out = append(out, "-global_quality", strconv.Itoa(qToQP(quality)))
- case hwAMF:
- out = append(out, "-quality", "speed", "-rc", "cqp", "-qp_i", strconv.Itoa(qToQP(quality)), "-qp_p", strconv.Itoa(qToQP(quality)))
- case hwVideoToolbox:
- out = append(out, "-q:v", strconv.Itoa(quality))
- default:
- if plan.wire == wireH265 {
- out = append(out, "-preset", "ultrafast", "-tune", "zerolatency", "-crf", strconv.Itoa(h264CRF(quality)))
- } else {
- out = append(out, "-preset", "ultrafast", "-tune", "zerolatency", "-crf", strconv.Itoa(h264CRF(quality)))
- }
- }
- mux := "h264"
- bsf := "h264_mp4toannexb"
- if plan.wire == wireH265 {
- mux = "hevc"
- bsf = "hevc_mp4toannexb"
- }
- out = append(out,
- "-g", gop,
- "-bf", "0",
- "-pix_fmt", "yuv420p",
- "-bsf:v", bsf,
- "-f", mux,
- "-",
- )
- return out
-}
-
-func (plan encoderPlan) ivfTail(fps, quality int) []string {
- gop := strconv.Itoa(fps * 2)
- out := []string{}
- switch plan.hwAccel {
- case hwVAAPI:
- out = append(out, "-vf", "format=nv12,hwupload")
- case hwQSV:
- out = append(out, "-vf", "format=nv12,hwupload=extra_hw_frames=8")
- }
- out = append(out, "-c:v", plan.ffmpegName)
- switch plan.wire {
- case wireVP9:
- switch plan.hwAccel {
- case hwVAAPI:
- out = append(out, "-rc_mode", "CQP", "-qp", strconv.Itoa(qToQP(quality)))
- case hwQSV:
- out = append(out, "-global_quality", strconv.Itoa(qToQP(quality)))
- default:
- out = append(out, "-deadline", "realtime", "-cpu-used", "8", "-crf", strconv.Itoa(qToCRF(quality)), "-b:v", "0")
- }
- case wireVP8:
- out = append(out, "-deadline", "realtime", "-cpu-used", "8", "-b:v", strconv.Itoa(qualityToBitrate(quality)))
- case wireAV1:
- switch plan.hwAccel {
- case hwNVENC:
- out = append(out, "-preset", "p4", "-rc", "vbr", "-cq", strconv.Itoa(qToCQ(quality)))
- case hwQSV:
- out = append(out, "-global_quality", strconv.Itoa(qToQP(quality)))
- case hwVAAPI:
- out = append(out, "-rc_mode", "CQP", "-qp", strconv.Itoa(qToQP(quality)))
- case hwAMF:
- out = append(out, "-rc", "cqp", "-qp_i", strconv.Itoa(qToQP(quality)), "-qp_p", strconv.Itoa(qToQP(quality)))
- default:
- if plan.ffmpegName == "libsvtav1" {
- out = append(out, "-preset", "10", "-crf", strconv.Itoa(qToCRF(quality)))
- } else {
- out = append(out, "-usage", "realtime", "-cpu-used", "8", "-crf", strconv.Itoa(qToCRF(quality)), "-b:v", "0")
- }
- }
- }
- out = append(out, "-g", gop, "-pix_fmt", "yuv420p", "-f", "ivf", "-")
- return out
-}
-
-func qToCRF(q int) int { return clampQuant(63 - (q * 53 / 100)) }
-func qToQP(q int) int { return clampQuant(51 - (q * 41 / 100)) }
-func qToCQ(q int) int { return clampQuant(51 - (q * 41 / 100)) }
-
-func clampQuant(v int) int {
- if v < 1 {
- return 1
- }
- if v > 63 {
- return 63
- }
- return v
-}
-
-func qualityToBitrate(quality int) int {
- // ~250 kbps–4 Mbps for VP8 realtime.
- return 250_000 + quality*37_500
-}
diff --git a/betterdesk-support-agent/signalhost/exchange.go b/betterdesk-support-agent/signalhost/exchange.go
deleted file mode 100644
index c4ba330b..00000000
--- a/betterdesk-support-agent/signalhost/exchange.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package signalhost
-
-import (
- "crypto/ed25519"
- "crypto/rand"
- "fmt"
-
- pb "github.com/unitronix/betterdesk-server/proto"
- "golang.org/x/crypto/nacl/box"
- "google.golang.org/protobuf/proto"
-)
-
-type ephemeralKeyPair struct {
- public [32]byte
- private [32]byte
-}
-
-func generateEphemeralKeyPair() (ephemeralKeyPair, error) {
- pub, priv, err := box.GenerateKey(rand.Reader)
- if err != nil {
- return ephemeralKeyPair{}, err
- }
- return ephemeralKeyPair{public: *pub, private: *priv}, nil
-}
-
-// buildSignedID proves that the ephemeral key belongs to this host identity.
-// The peer verifies the first 64 bytes against the Ed25519 key registered with
-// the signal server; zero-filled bytes are not a signature.
-func buildSignedID(deviceID string, pub [32]byte, signingKey ed25519.PrivateKey) (*pb.Message, error) {
- if len(signingKey) != ed25519.PrivateKeySize {
- return nil, fmt.Errorf("invalid host signing key")
- }
- idPk := &pb.IdPk{Id: deviceID, Pk: pub[:]}
- idPkBytes, err := proto.Marshal(idPk)
- if err != nil {
- return nil, err
- }
- signature := ed25519.Sign(signingKey, idPkBytes)
- signed := append(signature, idPkBytes...)
- return &pb.Message{
- Union: &pb.Message_SignedId{SignedId: &pb.SignedId{Id: signed}},
- }, nil
-}
-
-// openPublicKey decrypts the initiator's sealed symmetric key (responder role).
-func openPublicKey(our ephemeralKeyPair, theirPK, sealed []byte) ([32]byte, error) {
- if len(theirPK) != 32 {
- return [32]byte{}, fmt.Errorf("invalid initiator public key length %d", len(theirPK))
- }
- if len(sealed) != 48 {
- return [32]byte{}, fmt.Errorf("invalid sealed key length %d", len(sealed))
- }
- var peerPub, priv [32]byte
- copy(peerPub[:], theirPK)
- priv = our.private
- var nonce [24]byte
- opened, ok := box.Open(nil, sealed, &nonce, &peerPub, &priv)
- if !ok {
- return [32]byte{}, fmt.Errorf("nacl box open failed")
- }
- if len(opened) != 32 {
- return [32]byte{}, fmt.Errorf("symmetric key wrong length %d", len(opened))
- }
- var sym [32]byte
- copy(sym[:], opened)
- return sym, nil
-}
diff --git a/betterdesk-support-agent/signalhost/exchange_test.go b/betterdesk-support-agent/signalhost/exchange_test.go
deleted file mode 100644
index 339daea0..00000000
--- a/betterdesk-support-agent/signalhost/exchange_test.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package signalhost
-
-import (
- "crypto/ed25519"
- "crypto/rand"
- "testing"
-
- pb "github.com/unitronix/betterdesk-server/proto"
- "google.golang.org/protobuf/proto"
-)
-
-func TestBuildSignedIDUsesHostIdentitySignature(t *testing.T) {
- publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
- if err != nil {
- t.Fatal(err)
- }
- var ephemeral [32]byte
- if _, err := rand.Read(ephemeral[:]); err != nil {
- t.Fatal(err)
- }
- message, err := buildSignedID("BD-12345", ephemeral, privateKey)
- if err != nil {
- t.Fatal(err)
- }
- signed := message.GetSignedId().GetId()
- if len(signed) <= ed25519.SignatureSize {
- t.Fatalf("signed id length = %d", len(signed))
- }
- signature := signed[:ed25519.SignatureSize]
- payload := signed[ed25519.SignatureSize:]
- if !ed25519.Verify(publicKey, payload, signature) {
- t.Fatal("SignedId did not verify with the registered identity")
- }
- var idPK pb.IdPk
- if err := proto.Unmarshal(payload, &idPK); err != nil {
- t.Fatal(err)
- }
- if idPK.GetId() != "BD-12345" || string(idPK.GetPk()) != string(ephemeral[:]) {
- t.Fatalf("unexpected signed IdPk: id=%q public_key_len=%d", idPK.GetId(), len(idPK.GetPk()))
- }
-}
-
-func TestBuildSignedIDRejectsMissingIdentity(t *testing.T) {
- if _, err := buildSignedID("BD-12345", [32]byte{}, nil); err == nil {
- t.Fatal("expected missing identity rejection")
- }
-}
diff --git a/betterdesk-support-agent/signalhost/exec_other.go b/betterdesk-support-agent/signalhost/exec_other.go
deleted file mode 100644
index d9fe7719..00000000
--- a/betterdesk-support-agent/signalhost/exec_other.go
+++ /dev/null
@@ -1,7 +0,0 @@
-//go:build !windows
-
-package signalhost
-
-import "os/exec"
-
-func hideConsole(cmd *exec.Cmd) {}
diff --git a/betterdesk-support-agent/signalhost/exec_windows.go b/betterdesk-support-agent/signalhost/exec_windows.go
deleted file mode 100644
index 1ea54b97..00000000
--- a/betterdesk-support-agent/signalhost/exec_windows.go
+++ /dev/null
@@ -1,21 +0,0 @@
-//go:build windows
-
-package signalhost
-
-import (
- "os/exec"
- "syscall"
-)
-
-const createNoWindow = 0x08000000
-
-func hideConsole(cmd *exec.Cmd) {
- if cmd == nil {
- return
- }
- if cmd.SysProcAttr == nil {
- cmd.SysProcAttr = &syscall.SysProcAttr{}
- }
- cmd.SysProcAttr.HideWindow = true
- cmd.SysProcAttr.CreationFlags |= createNoWindow
-}
diff --git a/betterdesk-support-agent/signalhost/ffmpeg.go b/betterdesk-support-agent/signalhost/ffmpeg.go
deleted file mode 100644
index 31706d79..00000000
--- a/betterdesk-support-agent/signalhost/ffmpeg.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package signalhost
-
-import (
- "bytes"
- "context"
- "io"
- "os/exec"
- "strconv"
-)
-
-func startFFmpegCapture(ctx context.Context, args []string) (*exec.Cmd, io.ReadCloser, error) {
- path, err := exec.LookPath("ffmpeg")
- if err != nil {
- return nil, nil, err
- }
- cmd := exec.CommandContext(ctx, path, args...)
- hideConsole(cmd)
- stdout, err := cmd.StdoutPipe()
- if err != nil {
- return nil, nil, err
- }
- if err := cmd.Start(); err != nil {
- return nil, nil, err
- }
- return cmd, stdout, nil
-}
-
-func encodeJPEGToH264(ctx context.Context, jpeg []byte, quality int) ([]byte, bool, error) {
- path, err := exec.LookPath("ffmpeg")
- if err != nil {
- return nil, false, err
- }
- cmd := exec.CommandContext(ctx, path,
- "-hide_banner", "-loglevel", "error",
- "-f", "image2pipe", "-i", "pipe:0",
- "-frames:v", "1",
- "-c:v", "libx264",
- "-preset", "ultrafast",
- "-tune", "zerolatency",
- "-crf", strconv.Itoa(h264CRF(quality)),
- "-pix_fmt", "yuv420p",
- "-f", "h264", "pipe:1",
- )
- hideConsole(cmd)
- cmd.Stdin = bytes.NewReader(jpeg)
- out, err := cmd.Output()
- if err != nil {
- return nil, false, err
- }
- return out, h264HasIDR(out), nil
-}
-
-// h264HasIDR verifies that an encoded access unit actually contains an IDR
-// NAL. A fresh fallback encoder is expected to produce one, but the wire key
-// flag must describe the payload rather than an assumption about ffmpeg.
-func h264HasIDR(data []byte) bool {
- for offset := 0; ; {
- start := indexStartCode(data, offset)
- if start < 0 {
- return false
- }
- startLen := startCodeLen(data, start)
- if start+startLen >= len(data) {
- return false
- }
- if data[start+startLen]&0x1F == 5 {
- return true
- }
- offset = start + startLen
- }
-}
diff --git a/betterdesk-support-agent/signalhost/framing_ivf.go b/betterdesk-support-agent/signalhost/framing_ivf.go
deleted file mode 100644
index 80d4dab6..00000000
--- a/betterdesk-support-agent/signalhost/framing_ivf.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package signalhost
-
-import (
- "context"
- "encoding/binary"
- "io"
- "log"
-)
-
-func readIVFFrames(ctx context.Context, r io.Reader, wire string, onFrame func(frame []byte, keyframe bool)) {
- buf := make([]byte, 0, 512*1024)
- tmp := make([]byte, 65536)
- headerSkipped := false
-
- for {
- select {
- case <-ctx.Done():
- return
- default:
- }
-
- n, readErr := r.Read(tmp)
- if n > 0 {
- buf = append(buf, tmp[:n]...)
- }
-
- if !headerSkipped {
- if len(buf) < 32 {
- if readErr != nil {
- return
- }
- continue
- }
- if string(buf[0:4]) != "DKIF" {
- log.Printf("[signalhost] ivf: missing DKIF signature")
- return
- }
- buf = buf[32:]
- headerSkipped = true
- }
-
- for {
- if len(buf) < 12 {
- break
- }
- size := int(binary.LittleEndian.Uint32(buf[0:4]))
- if size <= 0 || size > 32*1024*1024 {
- log.Printf("[signalhost] ivf: bad frame size %d", size)
- return
- }
- if len(buf) < 12+size {
- break
- }
- payload := buf[12 : 12+size]
- frame := make([]byte, size)
- copy(frame, payload)
- onFrame(frame, isIVFKeyframe(wire, frame))
- buf = buf[12+size:]
- }
-
- if readErr != nil {
- return
- }
- }
-}
-
-func isIVFKeyframe(wire string, frame []byte) bool {
- switch wire {
- case wireVP9:
- return vp9IsKeyframe(frame)
- case wireVP8:
- return vp8IsKeyframe(frame)
- case wireAV1:
- return av1IsKeyframe(frame)
- default:
- return false
- }
-}
-
-func vp9IsKeyframe(f []byte) bool {
- if len(f) < 1 {
- return false
- }
- b := f[0]
- if (b >> 6) != 0b10 {
- return false
- }
- profile := (b >> 4) & 0x3
- bit := 4
- if profile == 3 {
- bit++
- }
- showExisting := (b >> (7 - bit)) & 1
- bit++
- if showExisting == 1 {
- return false
- }
- if bit > 7 {
- return false
- }
- frameType := (b >> (7 - bit)) & 1
- return frameType == 0
-}
-
-func vp8IsKeyframe(f []byte) bool {
- if len(f) < 1 {
- return false
- }
- return (f[0] & 1) == 0
-}
-
-func av1IsKeyframe(f []byte) bool {
- // Conservative: treat OBU_FRAME / FRAME_HEADER with show_frame and key type.
- // AV1 bit parsing is dense; many encoders put keyframes as first bit clear
- // in temporal delimiter sequences. Prefer false negatives over false keys.
- if len(f) < 2 {
- return false
- }
- // OBU type in first byte bits 3-6 (when forbidden bit is 0).
- obuType := (f[0] >> 3) & 0x0F
- // 1 = OBU_SEQUENCE_HEADER often precedes key; 3/6 = frame headers.
- return obuType == 1 || obuType == 3 || obuType == 6
-}
diff --git a/betterdesk-support-agent/signalhost/hardening_test.go b/betterdesk-support-agent/signalhost/hardening_test.go
deleted file mode 100644
index ee2337a8..00000000
--- a/betterdesk-support-agent/signalhost/hardening_test.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package signalhost
-
-import (
- "net"
- "testing"
- "time"
-)
-
-func TestValidLoginPasswordRejectsEmptyLocalPassword(t *testing.T) {
- salt, challenge := "salt", "challenge"
- emptyHash := hashPassword("", salt, challenge)
- if validLoginPassword("", salt, challenge, emptyHash[:]) {
- t.Fatal("empty local password must never authenticate")
- }
-
- password := "secret12"
- expected := hashPassword(password, salt, challenge)
- if !validLoginPassword(password, salt, challenge, expected[:]) {
- t.Fatal("configured password should authenticate its matching hash")
- }
-}
-
-func TestHostDoesNotStartWhenAccessIsDisabled(t *testing.T) {
- host := New(Config{
- SignalAddr: "127.0.0.1:21116",
- DeviceID: "BD-TEST",
- DesktopEnabled: true,
- AccessAllowed: func() bool {
- return false
- },
- })
- if host.Start() {
- t.Fatal("host started despite disabled access policy")
- }
- if host.Running() {
- t.Fatal("host reports running despite disabled access policy")
- }
-}
-
-func TestDisconnectSessionsClosesActiveRelay(t *testing.T) {
- server, client := net.Pipe()
- defer client.Close()
-
- host := New(Config{})
- host.sessions = map[net.Conn]struct{}{server: {}}
- host.DisconnectSessions()
-
- readDone := make(chan error, 1)
- go func() {
- _, err := client.Read(make([]byte, 1))
- readDone <- err
- }()
- select {
- case err := <-readDone:
- if err == nil {
- t.Fatal("relay read succeeded after local disconnect")
- }
- case <-time.After(time.Second):
- t.Fatal("relay connection was not closed by local disconnect")
- }
-}
diff --git a/betterdesk-support-agent/signalhost/host.go b/betterdesk-support-agent/signalhost/host.go
deleted file mode 100644
index b3fd21b1..00000000
--- a/betterdesk-support-agent/signalhost/host.go
+++ /dev/null
@@ -1,153 +0,0 @@
-package signalhost
-
-import (
- "context"
- "net"
- "sync"
-)
-
-// Config holds signal/relay host settings for BetterDesk-compatible clients.
-type Config struct {
- SignalAddr string
- RelayAddr string
- DeviceID string
- UUID []byte
- DataDir string
-
- Password func() string
- Unattended func() bool
- TOTPEnabled func() bool
- TOTPVerify func(code string) bool
- // AccessAllowed must return false as soon as local policy disables
- // RustDesk-compatible desktop access.
- AccessAllowed func() bool
- DesktopEnabled bool
- AudioEnabled bool
- RestartEnabled bool
- // Consent must be provided whenever Unattended returns false. A missing
- // callback is treated as denial rather than an unattended fallback.
- Consent func(operator string) bool
- OnSession func(start bool, operator string)
-}
-
-// Host maintains UDP registration with hbbs and accepts incoming relay sessions.
-type Host struct {
- cfg Config
-
- mu sync.Mutex
- cancel context.CancelFunc
- running bool
- identity *identity
- auth *authenticationLimiter
- wg sync.WaitGroup
-
- sessionsMu sync.Mutex
- sessions map[net.Conn]struct{}
-}
-
-func New(cfg Config) *Host {
- return &Host{
- cfg: cfg,
- auth: newAuthenticationLimiter(nil),
- }
-}
-
-// Start begins outbound rendezvous registration. It never opens a local
-// listener; relay sessions are initiated only after the rendezvous service
-// requests one.
-func (h *Host) Start() bool {
- h.mu.Lock()
- defer h.mu.Unlock()
- if h.running {
- return true
- }
- if h.cfg.SignalAddr == "" || h.cfg.DeviceID == "" || !h.accessAllowed() {
- return false
- }
- ctx, cancel := context.WithCancel(context.Background())
- h.cancel = cancel
- h.running = true
- h.sessions = make(map[net.Conn]struct{})
- h.wg.Add(1)
- go func() {
- defer func() {
- h.mu.Lock()
- h.running = false
- h.cancel = nil
- h.mu.Unlock()
- h.DisconnectSessions()
- h.wg.Done()
- }()
- h.runLoop(ctx)
- }()
- return true
-}
-
-func (h *Host) Stop() {
- h.mu.Lock()
- cancel := h.cancel
- h.cancel = nil
- h.running = false
- h.mu.Unlock()
- if cancel != nil {
- cancel()
- }
- h.DisconnectSessions()
- h.wg.Wait()
-}
-
-// DisconnectSessions terminates currently active relay sessions without
-// unregistering the host. It is used by the local "Disconnect" action.
-func (h *Host) DisconnectSessions() {
- h.sessionsMu.Lock()
- conns := make([]net.Conn, 0, len(h.sessions))
- for conn := range h.sessions {
- conns = append(conns, conn)
- }
- h.sessions = make(map[net.Conn]struct{})
- h.sessionsMu.Unlock()
- for _, conn := range conns {
- _ = conn.Close()
- }
-}
-
-// Running reports whether rendezvous registration is active.
-func (h *Host) Running() bool {
- h.mu.Lock()
- defer h.mu.Unlock()
- return h.running
-}
-
-func (h *Host) setIdentity(identity *identity) {
- h.mu.Lock()
- h.identity = identity
- h.mu.Unlock()
-}
-
-func (h *Host) hostIdentity() *identity {
- h.mu.Lock()
- defer h.mu.Unlock()
- return h.identity
-}
-
-func (h *Host) accessAllowed() bool {
- return h.cfg.DesktopEnabled && (h.cfg.AccessAllowed == nil || h.cfg.AccessAllowed())
-}
-
-func (h *Host) trackRelay(conn net.Conn) bool {
- h.mu.Lock()
- defer h.mu.Unlock()
- if !h.running || !h.accessAllowed() {
- return false
- }
- h.sessionsMu.Lock()
- h.sessions[conn] = struct{}{}
- h.sessionsMu.Unlock()
- return true
-}
-
-func (h *Host) untrackRelay(conn net.Conn) {
- h.sessionsMu.Lock()
- delete(h.sessions, conn)
- h.sessionsMu.Unlock()
-}
diff --git a/betterdesk-support-agent/signalhost/identity.go b/betterdesk-support-agent/signalhost/identity.go
deleted file mode 100644
index 5399a7f4..00000000
--- a/betterdesk-support-agent/signalhost/identity.go
+++ /dev/null
@@ -1,129 +0,0 @@
-package signalhost
-
-import (
- "crypto/ed25519"
- "crypto/rand"
- "encoding/hex"
- "errors"
- "fmt"
- "os"
- "path/filepath"
-)
-
-type identity struct {
- publicKey ed25519.PublicKey
- secretKey ed25519.PrivateKey
- uuid []byte
-}
-
-func loadIdentity(dataDir, deviceUUID string) (*identity, error) {
- if err := os.MkdirAll(dataDir, 0o700); err != nil {
- return nil, err
- }
- if err := os.Chmod(dataDir, 0o700); err != nil {
- return nil, err
- }
- skPath := filepath.Join(dataDir, "signal_ed25519")
- pkPath := filepath.Join(dataDir, "signal_ed25519.pub")
-
- id := &identity{}
- skData, err := os.ReadFile(skPath)
- switch {
- case err == nil:
- if len(skData) != ed25519.PrivateKeySize {
- return nil, fmt.Errorf("invalid stored signal identity key length")
- }
- id.secretKey = ed25519.PrivateKey(skData)
- id.publicKey = id.secretKey.Public().(ed25519.PublicKey)
- case !errors.Is(err, os.ErrNotExist):
- return nil, fmt.Errorf("read signal identity: %w", err)
- default:
- pub, priv, err := ed25519.GenerateKey(rand.Reader)
- if err != nil {
- return nil, err
- }
- id.publicKey = pub
- id.secretKey = priv
- file, err := os.OpenFile(skPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
- if err != nil {
- if !errors.Is(err, os.ErrExist) {
- return nil, fmt.Errorf("create signal identity: %w", err)
- }
- // Another process created the identity while this one was
- // generating it. Re-read rather than replacing that identity.
- skData, readErr := os.ReadFile(skPath)
- if readErr != nil {
- return nil, fmt.Errorf("read concurrently-created signal identity: %w", readErr)
- }
- if len(skData) != ed25519.PrivateKeySize {
- return nil, fmt.Errorf("invalid concurrently-created signal identity key length")
- }
- id.secretKey = ed25519.PrivateKey(skData)
- id.publicKey = id.secretKey.Public().(ed25519.PublicKey)
- } else {
- if _, err := file.Write(priv); err != nil {
- _ = file.Close()
- return nil, fmt.Errorf("write signal identity: %w", err)
- }
- if err := file.Close(); err != nil {
- return nil, fmt.Errorf("close signal identity: %w", err)
- }
- }
- }
- if err := os.WriteFile(pkPath, []byte(id.publicKey), 0o644); err != nil {
- return nil, err
- }
-
- if deviceUUID != "" {
- if b, err := hex.DecodeString(deviceUUID); err == nil && len(b) == 16 {
- id.uuid = b
- }
- }
- if len(id.uuid) == 0 {
- uuidPath := filepath.Join(dataDir, "signal_uuid")
- if existing, err := os.ReadFile(uuidPath); err == nil {
- if len(existing) != 16 {
- return nil, fmt.Errorf("invalid stored signal UUID length")
- }
- id.uuid = existing
- } else if !errors.Is(err, os.ErrNotExist) {
- return nil, fmt.Errorf("read signal UUID: %w", err)
- } else {
- id.uuid = make([]byte, 16)
- if _, err := rand.Read(id.uuid); err != nil {
- return nil, err
- }
- file, err := os.OpenFile(uuidPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
- if err != nil {
- if !errors.Is(err, os.ErrExist) {
- return nil, fmt.Errorf("create signal UUID: %w", err)
- }
- existing, readErr := os.ReadFile(uuidPath)
- if readErr != nil {
- return nil, fmt.Errorf("read concurrently-created signal UUID: %w", readErr)
- }
- if len(existing) != 16 {
- return nil, fmt.Errorf("invalid concurrently-created signal UUID length")
- }
- id.uuid = existing
- } else {
- if _, err := file.Write(id.uuid); err != nil {
- _ = file.Close()
- return nil, fmt.Errorf("write signal UUID: %w", err)
- }
- if err := file.Close(); err != nil {
- return nil, fmt.Errorf("close signal UUID: %w", err)
- }
- }
- }
- }
- return id, nil
-}
-
-func (id *identity) pkBytes() []byte {
- return []byte(id.publicKey)
-}
-
-func (id *identity) String() string {
- return fmt.Sprintf("pk=%dB uuid=%dB", len(id.publicKey), len(id.uuid))
-}
diff --git a/betterdesk-support-agent/signalhost/identity_test.go b/betterdesk-support-agent/signalhost/identity_test.go
deleted file mode 100644
index d7d3d397..00000000
--- a/betterdesk-support-agent/signalhost/identity_test.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package signalhost
-
-import (
- "bytes"
- "os"
- "path/filepath"
- "runtime"
- "testing"
-)
-
-func TestLoadIdentityPersistsKeyAndUUID(t *testing.T) {
- dir := t.TempDir()
- first, err := loadIdentity(dir, "")
- if err != nil {
- t.Fatal(err)
- }
- second, err := loadIdentity(dir, "")
- if err != nil {
- t.Fatal(err)
- }
- if !bytes.Equal(first.publicKey, second.publicKey) {
- t.Fatal("signal public key changed across restart")
- }
- if !bytes.Equal(first.uuid, second.uuid) {
- t.Fatal("signal UUID changed across restart")
- }
-
- if runtime.GOOS != "windows" {
- info, err := os.Stat(filepath.Join(dir, "signal_ed25519"))
- if err != nil {
- t.Fatal(err)
- }
- if info.Mode().Perm()&0o077 != 0 {
- t.Fatalf("private key permissions = %o, want owner-only", info.Mode().Perm())
- }
- }
-}
-
-func TestLoadIdentityRefusesCorruptStoredKey(t *testing.T) {
- dir := t.TempDir()
- if err := os.WriteFile(filepath.Join(dir, "signal_ed25519"), []byte("bad"), 0o600); err != nil {
- t.Fatal(err)
- }
- if _, err := loadIdentity(dir, ""); err == nil {
- t.Fatal("corrupt signal identity was silently replaced")
- }
-}
diff --git a/betterdesk-support-agent/signalhost/incoming.go b/betterdesk-support-agent/signalhost/incoming.go
deleted file mode 100644
index 3548c1bf..00000000
--- a/betterdesk-support-agent/signalhost/incoming.go
+++ /dev/null
@@ -1,292 +0,0 @@
-package signalhost
-
-import (
- "context"
- "crypto/rand"
- "crypto/subtle"
- "encoding/hex"
- "fmt"
- "io"
- "log"
- "net"
- "runtime"
- "strings"
- "time"
-
- "github.com/unitronix/betterdesk-server/codec"
- pb "github.com/unitronix/betterdesk-server/proto"
- "google.golang.org/protobuf/proto"
-)
-
-const (
- loginErr2FARequired = "2FA Required"
- publicKeyWait = 1500 * time.Millisecond
-)
-
-func (h *Host) handleIncomingRelay(ctx context.Context, relayServer, uuid string) {
- if ctx.Err() != nil || !h.accessAllowed() {
- return
- }
- addr := relayServer
- if !hasPort(addr) {
- addr = net.JoinHostPort(addr, "21117")
- }
- if h.cfg.RelayAddr != "" && relayServer == "" {
- addr = h.cfg.RelayAddr
- }
-
- conn, err := (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, "tcp", addr)
- if err != nil {
- if ctx.Err() == nil {
- log.Printf("[signalhost] relay dial: %v", err)
- }
- return
- }
- defer conn.Close()
- if !h.trackRelay(conn) {
- return
- }
- defer h.untrackRelay(conn)
-
- req := &pb.RendezvousMessage{
- Union: &pb.RendezvousMessage_RequestRelay{
- RequestRelay: &pb.RequestRelay{
- Id: h.cfg.DeviceID,
- Uuid: uuid,
- },
- },
- }
- if err := codec.WriteRawProto(conn, req); err != nil {
- log.Printf("[signalhost] RequestRelay: %v", err)
- return
- }
-
- skipRelayConfirm(conn)
-
- if err := h.runPeerSession(conn); err != nil {
- log.Printf("[signalhost] session ended: %v", err)
- }
-}
-
-func skipRelayConfirm(conn net.Conn) {
- raw, err := readPeerFrame(conn, 2*time.Second)
- if err != nil {
- return
- }
- rdz := &pb.RendezvousMessage{}
- if err := proto.Unmarshal(raw, rdz); err != nil {
- log.Printf("[signalhost] relay first frame not rendezvous (len=%d)", len(raw))
- return
- }
- if rdz.GetRelayResponse() != nil {
- log.Printf("[signalhost] skipped relay confirmation")
- }
-}
-
-func hasPort(hostport string) bool {
- _, port, err := net.SplitHostPort(hostport)
- return err == nil && port != ""
-}
-
-func (h *Host) runPeerSession(conn net.Conn) error {
- if !h.accessAllowed() {
- return fmt.Errorf("remote access disabled")
- }
- identity := h.hostIdentity()
- if identity == nil || len(identity.secretKey) == 0 {
- return fmt.Errorf("host identity is unavailable")
- }
- ephemeral, err := generateEphemeralKeyPair()
- if err != nil {
- return err
- }
-
- ps := newPeerSession(conn)
-
- signedID, err := buildSignedID(h.cfg.DeviceID, ephemeral.public, identity.secretKey)
- if err != nil {
- return err
- }
- if err := ps.write(signedID); err != nil {
- return err
- }
- log.Printf("[signalhost] sent SignedId (device=%s)", h.cfg.DeviceID)
-
- // A session must establish the authenticated encrypted channel before
- // credentials or desktop data are exchanged. There is no plaintext fallback.
- if err := h.waitPublicKey(ps, ephemeral); err != nil {
- return fmt.Errorf("authenticated key exchange: %w", err)
- }
-
- salt := randomToken()
- challenge := randomToken()
- if err := ps.write(&pb.Message{Union: &pb.Message_Hash{Hash: &pb.Hash{Salt: salt, Challenge: challenge}}}); err != nil {
- return err
- }
-
- frame, err := ps.read(60 * time.Second)
- if err != nil {
- return err
- }
- login := frame.GetLoginRequest()
- if login == nil {
- return fmt.Errorf("expected LoginRequest, got %T", frame.GetUnion())
- }
-
- operator := login.GetMyName()
- if operator == "" {
- operator = login.GetMyId()
- }
- if h.auth != nil && !h.auth.allow(operator) {
- _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{
- Union: &pb.LoginResponse_Error{Error: "Too many authentication attempts"},
- }}})
- return fmt.Errorf("authentication temporarily locked")
- }
-
- pw := ""
- if h.cfg.Password != nil {
- pw = h.cfg.Password()
- }
- pw = strings.TrimSpace(pw)
-
- if pw == "" {
- _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{
- Union: &pb.LoginResponse_Error{Error: "Access password unavailable"},
- }}})
- return fmt.Errorf("local access password is empty")
- }
- if !validLoginPassword(pw, salt, challenge, login.GetPassword()) {
- if h.auth != nil {
- h.auth.failure(operator)
- }
- _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{
- Union: &pb.LoginResponse_Error{Error: "Wrong Password"},
- }}})
- return fmt.Errorf("wrong password")
- }
-
- if h.cfg.TOTPEnabled != nil && h.cfg.TOTPEnabled() {
- if err := ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{
- Union: &pb.LoginResponse_Error{Error: loginErr2FARequired},
- }}}); err != nil {
- return err
- }
- authFrame, err := ps.read(60 * time.Second)
- if err != nil {
- return err
- }
- auth2fa := authFrame.GetAuth_2Fa()
- if auth2fa == nil || h.cfg.TOTPVerify == nil || !h.cfg.TOTPVerify(auth2fa.GetCode()) {
- if h.auth != nil {
- h.auth.failure(operator)
- }
- _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{
- Union: &pb.LoginResponse_Error{Error: "Wrong 2FA code"},
- }}})
- return fmt.Errorf("wrong 2fa code")
- }
- }
- if h.auth != nil {
- h.auth.success(operator)
- }
-
- if !h.accessAllowed() {
- _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{
- Union: &pb.LoginResponse_Error{Error: "Remote access disabled"},
- }}})
- return fmt.Errorf("remote access disabled")
- }
-
- if !h.authorizesOperator(operator) {
- _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{
- Union: &pb.LoginResponse_Error{Error: "Connection denied"},
- }}})
- return fmt.Errorf("consent denied")
- }
- if !h.accessAllowed() {
- _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{
- Union: &pb.LoginResponse_Error{Error: "Remote access disabled"},
- }}})
- return fmt.Errorf("remote access disabled")
- }
-
- encoding := advertisedVideoEncoding()
- codec := negotiateVideoCodec(encoding, login.GetOption().GetSupportedDecoding())
- if codec == videoCodecNone {
- _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{
- Union: &pb.LoginResponse_Error{Error: "No mutually supported desktop video codec"},
- }}})
- return fmt.Errorf("no mutually supported desktop video codec")
- }
-
- if h.cfg.OnSession != nil {
- h.cfg.OnSession(true, operator)
- defer h.cfg.OnSession(false, operator)
- }
-
- peerInfo, _, _ := buildPeerInfo(h.cfg.DeviceID, encoding)
- if err := ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{
- Union: &pb.LoginResponse_PeerInfo{PeerInfo: peerInfo},
- }}}); err != nil {
- return err
- }
- log.Printf("[signalhost] authenticated operator=%s encrypted=%v platform=%s codec=%s", operator, ps.encrypted, runtime.GOOS, codec)
-
- return h.streamSession(ps, codec, login.GetOption())
-}
-
-// authorizesOperator evaluates the current local policy immediately before a
-// session starts. A non-unattended host must fail closed when no local consent
-// callback is available; accepting the password alone would bypass supervised
-// mode. Re-evaluating Unattended here also prevents a policy change during the
-// login exchange from retaining an earlier unattended decision.
-func (h *Host) authorizesOperator(operator string) bool {
- if h.cfg.Unattended != nil && h.cfg.Unattended() {
- return true
- }
- return h.cfg.Consent != nil && h.cfg.Consent(operator)
-}
-
-func (h *Host) waitPublicKey(ps *peerSession, ephemeral ephemeralKeyPair) error {
- raw, err := readPeerFrame(ps.conn, publicKeyWait)
- if err != nil {
- if err == io.EOF {
- return fmt.Errorf("connection closed before PublicKey")
- }
- return err
- }
-
- msg := &pb.Message{}
- if err := proto.Unmarshal(raw, msg); err != nil {
- return fmt.Errorf("decode while waiting PublicKey: %w", err)
- }
- pk := msg.GetPublicKey()
- if pk == nil {
- return fmt.Errorf("first peer frame was not PublicKey")
- }
- theirPK := pk.GetAsymmetricValue()
- sealed := pk.GetSymmetricValue()
- symKey, err := openPublicKey(ephemeral, theirPK, sealed)
- if err != nil {
- return err
- }
- ps.enableEncryption(symKey)
- log.Printf("[signalhost] RustDesk encryption enabled (peer pk %d bytes)", len(theirPK))
- return nil
-}
-
-func randomToken() string {
- b := make([]byte, 16)
- _, _ = rand.Read(b)
- return hex.EncodeToString(b)
-}
-
-func validLoginPassword(password, salt, challenge string, provided []byte) bool {
- password = strings.TrimSpace(password)
- if password == "" {
- return false
- }
- expected := hashPassword(password, salt, challenge)
- return subtle.ConstantTimeCompare(provided, expected[:]) == 1
-}
diff --git a/betterdesk-support-agent/signalhost/peer_codec.go b/betterdesk-support-agent/signalhost/peer_codec.go
deleted file mode 100644
index e31ab02c..00000000
--- a/betterdesk-support-agent/signalhost/peer_codec.go
+++ /dev/null
@@ -1,101 +0,0 @@
-package signalhost
-
-import (
- "encoding/binary"
- "fmt"
- "io"
- "net"
- "time"
-)
-
-// MaxPeerFrameSize matches RustDesk desktop clients (16 MiB video frames).
-const MaxPeerFrameSize = 16 * 1024 * 1024
-
-func writePeerFrame(conn net.Conn, data []byte) error {
- if len(data) > MaxPeerFrameSize {
- return fmt.Errorf("signalhost: frame too large (%d > %d)", len(data), MaxPeerFrameSize)
- }
- header := encodePeerHeader(len(data))
- frame := make([]byte, len(header)+len(data))
- copy(frame, header)
- copy(frame[len(header):], data)
- _, err := conn.Write(frame)
- return err
-}
-
-func readPeerFrame(conn net.Conn, timeout time.Duration) ([]byte, error) {
- if timeout > 0 {
- if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
- return nil, err
- }
- defer conn.SetReadDeadline(time.Time{})
- }
-
- _, payloadLen, err := readPeerHeader(conn)
- if err != nil {
- return nil, err
- }
- if payloadLen == 0 {
- return nil, fmt.Errorf("signalhost: zero-length frame")
- }
- if payloadLen > MaxPeerFrameSize {
- return nil, fmt.Errorf("signalhost: frame too large (%d > %d)", payloadLen, MaxPeerFrameSize)
- }
- payload := make([]byte, payloadLen)
- if _, err := io.ReadFull(conn, payload); err != nil {
- return nil, err
- }
- return payload, nil
-}
-
-func encodePeerHeader(payloadLen int) []byte {
- if payloadLen <= 0x3F {
- return []byte{byte(payloadLen << 2)}
- }
- if payloadLen <= 0x3FFF {
- val := uint16(payloadLen<<2) | 0x01
- buf := make([]byte, 2)
- binary.LittleEndian.PutUint16(buf, val)
- return buf
- }
- if payloadLen <= 0x3FFFFF {
- val := uint32(payloadLen<<2) | 0x02
- return []byte{byte(val), byte(val >> 8), byte(val >> 16)}
- }
- val := uint32(payloadLen<<2) | 0x03
- buf := make([]byte, 4)
- binary.LittleEndian.PutUint32(buf, val)
- return buf
-}
-
-func readPeerHeader(conn net.Conn) (headerLen int, payloadLen int, err error) {
- var first [1]byte
- if _, err := io.ReadFull(conn, first[:]); err != nil {
- return 0, 0, err
- }
- headLen := int(first[0]&0x03) + 1
- var n uint32
- switch headLen {
- case 1:
- n = uint32(first[0])
- case 2:
- var second [1]byte
- if _, err := io.ReadFull(conn, second[:]); err != nil {
- return 0, 0, err
- }
- n = uint32(first[0]) | uint32(second[0])<<8
- case 3:
- var rest [2]byte
- if _, err := io.ReadFull(conn, rest[:]); err != nil {
- return 0, 0, err
- }
- n = uint32(first[0]) | uint32(rest[0])<<8 | uint32(rest[1])<<16
- case 4:
- var rest [3]byte
- if _, err := io.ReadFull(conn, rest[:]); err != nil {
- return 0, 0, err
- }
- n = uint32(first[0]) | uint32(rest[0])<<8 | uint32(rest[1])<<16 | uint32(rest[2])<<24
- }
- return headLen, int(n >> 2), nil
-}
diff --git a/betterdesk-support-agent/signalhost/peer_codec_fuzz_test.go b/betterdesk-support-agent/signalhost/peer_codec_fuzz_test.go
deleted file mode 100644
index a40d76bb..00000000
--- a/betterdesk-support-agent/signalhost/peer_codec_fuzz_test.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package signalhost
-
-import (
- "bytes"
- "net"
- "testing"
- "time"
-)
-
-const maxPeerFrameFuzzInput = 64 * 1024
-
-type fuzzPeerConn struct {
- reader *bytes.Reader
-}
-
-func newFuzzPeerConn(data []byte) *fuzzPeerConn {
- return &fuzzPeerConn{reader: bytes.NewReader(data)}
-}
-
-func (c *fuzzPeerConn) Read(p []byte) (int, error) { return c.reader.Read(p) }
-func (c *fuzzPeerConn) Write(p []byte) (int, error) { return len(p), nil }
-func (c *fuzzPeerConn) Close() error { return nil }
-func (c *fuzzPeerConn) LocalAddr() net.Addr { return fuzzPeerAddr("local") }
-func (c *fuzzPeerConn) RemoteAddr() net.Addr { return fuzzPeerAddr("remote") }
-func (c *fuzzPeerConn) SetDeadline(time.Time) error { return nil }
-func (c *fuzzPeerConn) SetReadDeadline(time.Time) error { return nil }
-func (c *fuzzPeerConn) SetWriteDeadline(time.Time) error { return nil }
-
-type fuzzPeerAddr string
-
-func (a fuzzPeerAddr) Network() string { return "fuzz" }
-func (a fuzzPeerAddr) String() string { return string(a) }
-
-// FuzzPeerFrameFraming exercises both malformed peer headers and bounded,
-// well-framed payloads without allowing a fuzz case to request a large buffer.
-func FuzzPeerFrameFraming(f *testing.F) {
- f.Add([]byte{})
- f.Add([]byte{0x04, 'a'})
- f.Add([]byte{0xff, 0xff, 0xff, 0xff})
- f.Add(append(encodePeerHeader(3), []byte("abc")...))
-
- f.Fuzz(func(t *testing.T, raw []byte) {
- if len(raw) > maxPeerFrameFuzzInput {
- t.Skip()
- }
-
- // Exercise arbitrary headers only when the declared payload is small
- // enough for a bounded fuzz run. Oversized headers are still parsed.
- if _, payloadLen, err := readPeerHeader(newFuzzPeerConn(raw)); err == nil && payloadLen <= maxPeerFrameFuzzInput {
- _, _ = readPeerFrame(newFuzzPeerConn(raw), 0)
- }
-
- // A known-valid framing must round-trip the exact payload.
- wire := append(encodePeerHeader(len(raw)), raw...)
- got, err := readPeerFrame(newFuzzPeerConn(wire), 0)
- if len(raw) == 0 {
- if err == nil {
- t.Fatal("zero-length peer frame was accepted")
- }
- return
- }
- if err != nil {
- t.Fatalf("read valid peer frame: %v", err)
- }
- if !bytes.Equal(got, raw) {
- t.Fatalf("peer frame payload mismatch: got %d bytes, want %d", len(got), len(raw))
- }
- })
-}
diff --git a/betterdesk-support-agent/signalhost/peer_input.go b/betterdesk-support-agent/signalhost/peer_input.go
deleted file mode 100644
index 8b1f501a..00000000
--- a/betterdesk-support-agent/signalhost/peer_input.go
+++ /dev/null
@@ -1,204 +0,0 @@
-package signalhost
-
-import (
- "log"
- "time"
-
- bdagent "github.com/unitronix/betterdesk-agent/agent"
- pb "github.com/unitronix/betterdesk-server/proto"
-)
-
-func (h *Host) handlePeerMessage(msg *pb.Message, st *streamState) {
- if !h.accessAllowed() {
- return
- }
- if me := msg.GetMouseEvent(); me != nil {
- if h.cfg.DesktopEnabled {
- injectMouse(me)
- }
- return
- }
- if ke := msg.GetKeyEvent(); ke != nil {
- if h.cfg.DesktopEnabled {
- injectKey(ke)
- }
- return
- }
- if msg.GetAudioFrame() != nil {
- // Audio capture/playback is not implemented in the support host. Keep
- // the policy check explicit so a future bridge cannot bypass branding.
- if !h.cfg.AudioEnabled {
- return
- }
- return
- }
- if misc := msg.GetMisc(); misc != nil {
- if misc.GetRestartRemoteDevice() {
- // The host deliberately has no privileged remote-restart handler.
- // Consult the policy before ignoring the request so adding one later
- // cannot accidentally bypass a disabled restart capability.
- if !h.cfg.RestartEnabled {
- return
- }
- return
- }
- if option := misc.GetOption(); option != nil {
- if st != nil && st.applyPeerOptions(option, time.Now()) {
- log.Printf("[signalhost] applied bounded video settings update")
- }
- return
- }
- if misc.GetRefreshVideo() || misc.GetRefreshVideoDisplay() != 0 {
- if st != nil && st.requestKeyframe(time.Now()) {
- log.Printf("[signalhost] refresh video requested")
- }
- }
- return
- }
- if msg.GetTestDelay() != nil {
- return
- }
-}
-
-func injectMouse(me *pb.MouseEvent) {
- if me == nil {
- return
- }
- mask := me.GetMask()
- x, y := int(me.GetX()), int(me.GetY())
- buttonID := mask >> 3
- eventType := mask & 7
-
- evt := &bdagent.InputEvent{X: x, Y: y}
- switch eventType {
- case 0:
- evt.Type = "mouse_move"
- case 1:
- evt.Type = "mouse_down"
- evt.Button = rustDeskButton(buttonID)
- case 2:
- evt.Type = "mouse_up"
- evt.Button = rustDeskButton(buttonID)
- case 3:
- evt.Type = "mouse_scroll"
- evt.DeltaY = y
- if y == 0 {
- evt.DeltaY = -1
- }
- default:
- evt.Type = "mouse_move"
- }
- if err := bdagent.InjectInputEvent(evt); err != nil {
- log.Printf("[signalhost] mouse inject: %v", err)
- }
-}
-
-func rustDeskButton(id int32) int {
- switch id {
- case 2:
- return 3 // right
- case 4:
- return 2 // middle
- default:
- return 1 // left
- }
-}
-
-func injectKey(ke *pb.KeyEvent) {
- if ke == nil {
- return
- }
- pressed := ke.GetDown() || ke.GetPress()
- evt := &bdagent.InputEvent{Pressed: pressed}
-
- switch u := ke.GetUnion().(type) {
- case *pb.KeyEvent_ControlKey:
- if pressed {
- evt.Type = "key_press"
- } else {
- evt.Type = "key_release"
- }
- evt.Key = controlKeyName(u.ControlKey)
- case *pb.KeyEvent_Chr:
- evt.Type = "text"
- evt.Text = string(rune(u.Chr))
- case *pb.KeyEvent_Unicode:
- evt.Type = "text"
- evt.Text = string(rune(u.Unicode))
- case *pb.KeyEvent_Seq:
- evt.Type = "text"
- evt.Text = u.Seq
- default:
- return
- }
- if err := bdagent.InjectInputEvent(evt); err != nil {
- log.Printf("[signalhost] key inject: %v", err)
- }
-}
-
-func controlKeyName(key pb.ControlKey) string {
- switch key {
- case pb.ControlKey_Backspace:
- return "BackSpace"
- case pb.ControlKey_Tab:
- return "Tab"
- case pb.ControlKey_Return:
- return "Return"
- case pb.ControlKey_Escape:
- return "Escape"
- case pb.ControlKey_Delete:
- return "Delete"
- case pb.ControlKey_Home:
- return "Home"
- case pb.ControlKey_End:
- return "End"
- case pb.ControlKey_PageUp:
- return "Page_Up"
- case pb.ControlKey_PageDown:
- return "Page_Down"
- case pb.ControlKey_UpArrow:
- return "Up"
- case pb.ControlKey_DownArrow:
- return "Down"
- case pb.ControlKey_LeftArrow:
- return "Left"
- case pb.ControlKey_RightArrow:
- return "Right"
- case pb.ControlKey_F1:
- return "F1"
- case pb.ControlKey_F2:
- return "F2"
- case pb.ControlKey_F3:
- return "F3"
- case pb.ControlKey_F4:
- return "F4"
- case pb.ControlKey_F5:
- return "F5"
- case pb.ControlKey_F6:
- return "F6"
- case pb.ControlKey_F7:
- return "F7"
- case pb.ControlKey_F8:
- return "F8"
- case pb.ControlKey_F9:
- return "F9"
- case pb.ControlKey_F10:
- return "F10"
- case pb.ControlKey_F11:
- return "F11"
- case pb.ControlKey_F12:
- return "F12"
- case pb.ControlKey_Shift:
- return "Shift_L"
- case pb.ControlKey_Control:
- return "Control_L"
- case pb.ControlKey_Alt:
- return "Alt_L"
- case pb.ControlKey_Meta:
- return "Super_L"
- case pb.ControlKey_Space:
- return "space"
- default:
- return key.String()
- }
-}
diff --git a/betterdesk-support-agent/signalhost/peer_session.go b/betterdesk-support-agent/signalhost/peer_session.go
deleted file mode 100644
index 82ad29d9..00000000
--- a/betterdesk-support-agent/signalhost/peer_session.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package signalhost
-
-import (
- "net"
- "time"
-
- pb "github.com/unitronix/betterdesk-server/proto"
- "google.golang.org/protobuf/proto"
-)
-
-// peerSession wraps a relay TCP connection with optional RustDesk secretbox encryption.
-type peerSession struct {
- conn net.Conn
- encrypted bool
- box *secretBoxStream
-}
-
-func newPeerSession(conn net.Conn) *peerSession {
- return &peerSession{conn: conn}
-}
-
-func (ps *peerSession) enableEncryption(key [32]byte) {
- ps.box = newSecretBoxStream(key)
- ps.encrypted = true
-}
-
-func (ps *peerSession) write(msg *pb.Message) error {
- data, err := proto.Marshal(msg)
- if err != nil {
- return err
- }
- if ps.encrypted {
- data, err = ps.box.encrypt(data)
- if err != nil {
- return err
- }
- }
- return writePeerFrame(ps.conn, data)
-}
-
-func (ps *peerSession) read(timeout time.Duration) (*pb.Message, error) {
- raw, err := readPeerFrame(ps.conn, timeout)
- if err != nil {
- return nil, err
- }
- if ps.encrypted {
- raw, err = ps.box.decrypt(raw)
- if err != nil {
- return nil, err
- }
- }
- out := &pb.Message{}
- if err := proto.Unmarshal(raw, out); err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func (ps *peerSession) readRaw(timeout time.Duration) ([]byte, error) {
- raw, err := readPeerFrame(ps.conn, timeout)
- if err != nil {
- return nil, err
- }
- if ps.encrypted {
- return ps.box.decrypt(raw)
- }
- return raw, nil
-}
diff --git a/betterdesk-support-agent/signalhost/secretbox.go b/betterdesk-support-agent/signalhost/secretbox.go
deleted file mode 100644
index e17eba6e..00000000
--- a/betterdesk-support-agent/signalhost/secretbox.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package signalhost
-
-import (
- "encoding/binary"
- "fmt"
-
- "golang.org/x/crypto/nacl/secretbox"
-)
-
-// secretBoxStream implements RustDesk counter-based XSalsa20-Poly1305 framing.
-type secretBoxStream struct {
- key [32]byte
- sendSeq uint64
- recvSeq uint64
-}
-
-func newSecretBoxStream(key [32]byte) *secretBoxStream {
- return &secretBoxStream{key: key}
-}
-
-func (s *secretBoxStream) encrypt(plaintext []byte) ([]byte, error) {
- s.sendSeq++
- var nonce [24]byte
- binary.LittleEndian.PutUint64(nonce[:8], s.sendSeq)
- return secretbox.Seal(nil, plaintext, &nonce, &s.key), nil
-}
-
-func (s *secretBoxStream) decrypt(ciphertext []byte) ([]byte, error) {
- s.recvSeq++
- var nonce [24]byte
- binary.LittleEndian.PutUint64(nonce[:8], s.recvSeq)
- plain, ok := secretbox.Open(nil, ciphertext, &nonce, &s.key)
- if !ok {
- return nil, fmt.Errorf("secretbox decrypt failed (seq=%d)", s.recvSeq)
- }
- return plain, nil
-}
diff --git a/betterdesk-support-agent/signalhost/signal.go b/betterdesk-support-agent/signalhost/signal.go
deleted file mode 100644
index e216783c..00000000
--- a/betterdesk-support-agent/signalhost/signal.go
+++ /dev/null
@@ -1,175 +0,0 @@
-package signalhost
-
-import (
- "context"
- "errors"
- "log"
- "net"
- "time"
-
- "github.com/unitronix/betterdesk-server/codec"
- pb "github.com/unitronix/betterdesk-server/proto"
-)
-
-const (
- heartbeatInterval = 12 * time.Second
- udpTimeout = 3 * time.Second
-)
-
-var errAccessDisabled = errors.New("incoming access disabled")
-
-func (h *Host) runLoop(ctx context.Context) {
- for {
- if ctx.Err() != nil {
- return
- }
- if !h.accessAllowed() {
- return
- }
- if err := h.runUDP(ctx); err != nil {
- if errors.Is(err, errAccessDisabled) {
- return
- }
- log.Printf("[signalhost] loop error: %v", err)
- }
- select {
- case <-ctx.Done():
- return
- case <-time.After(5 * time.Second):
- }
- }
-}
-
-func (h *Host) runUDP(ctx context.Context) error {
- if !h.accessAllowed() {
- return errAccessDisabled
- }
- id, err := loadIdentity(h.cfg.DataDir, "")
- if err != nil {
- return err
- }
- h.setIdentity(id)
-
- conn, err := net.Dial("udp", h.cfg.SignalAddr)
- if err != nil {
- return err
- }
- defer conn.Close()
- closeWatcher := make(chan struct{})
- defer close(closeWatcher)
- go func() {
- select {
- case <-ctx.Done():
- _ = conn.Close()
- case <-closeWatcher:
- }
- }()
- _ = conn.SetDeadline(time.Time{})
-
- var serial int32
- sendRegister := func() error {
- msg := &pb.RendezvousMessage{
- Union: &pb.RendezvousMessage_RegisterPeer{
- RegisterPeer: &pb.RegisterPeer{Id: h.cfg.DeviceID, Serial: serial},
- },
- }
- serial++
- data, err := codec.EncodeUDP(msg)
- if err != nil {
- return err
- }
- _, err = conn.Write(data)
- return err
- }
-
- if err := sendRegister(); err != nil {
- return err
- }
- _ = conn.SetReadDeadline(time.Now().Add(udpTimeout))
- buf := make([]byte, 4096)
- if n, err := conn.Read(buf); err == nil {
- h.handleUDPMessage(ctx, conn, id, buf[:n])
- }
-
- ticker := time.NewTicker(heartbeatInterval)
- defer ticker.Stop()
-
- for {
- select {
- case <-ctx.Done():
- return nil
- case <-ticker.C:
- if !h.accessAllowed() {
- return errAccessDisabled
- }
- if err := sendRegister(); err != nil {
- return err
- }
- default:
- }
- if !h.accessAllowed() {
- return errAccessDisabled
- }
-
- _ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
- n, err := conn.Read(buf)
- if err != nil {
- if ne, ok := err.(net.Error); ok && ne.Timeout() {
- continue
- }
- return err
- }
- h.handleUDPMessage(ctx, conn, id, buf[:n])
- }
-}
-
-func (h *Host) handleUDPMessage(ctx context.Context, conn net.Conn, id *identity, data []byte) {
- if !h.accessAllowed() {
- return
- }
- msg, err := codec.DecodeUDP(data)
- if err != nil {
- return
- }
- switch u := msg.Union.(type) {
- case *pb.RendezvousMessage_RegisterPeerResponse:
- if u.RegisterPeerResponse.GetRequestPk() {
- h.sendRegisterPk(conn, id)
- }
- case *pb.RendezvousMessage_RelayResponse:
- rr := u.RelayResponse
- if rr.GetUuid() != "" && rr.GetRelayServer() != "" {
- go h.handleIncomingRelay(ctx, rr.GetRelayServer(), rr.GetUuid())
- }
- case *pb.RendezvousMessage_RequestRelay:
- rr := u.RequestRelay
- relay := rr.GetRelayServer()
- if relay == "" {
- relay = h.cfg.RelayAddr
- }
- if rr.GetUuid() != "" {
- go h.handleIncomingRelay(ctx, relay, rr.GetUuid())
- }
- }
-}
-
-func (h *Host) sendRegisterPk(conn net.Conn, id *identity) {
- uuidBytes := h.cfg.UUID
- if len(uuidBytes) == 0 {
- uuidBytes = id.uuid
- }
- msg := &pb.RendezvousMessage{
- Union: &pb.RendezvousMessage_RegisterPk{
- RegisterPk: &pb.RegisterPk{
- Id: h.cfg.DeviceID,
- Uuid: uuidBytes,
- Pk: id.pkBytes(),
- },
- },
- }
- data, err := codec.EncodeUDP(msg)
- if err != nil {
- return
- }
- _, _ = conn.Write(data)
-}
diff --git a/betterdesk-support-agent/signalhost/stream.go b/betterdesk-support-agent/signalhost/stream.go
deleted file mode 100644
index 1872c904..00000000
--- a/betterdesk-support-agent/signalhost/stream.go
+++ /dev/null
@@ -1,358 +0,0 @@
-package signalhost
-
-import (
- "context"
- "io"
- "log"
- "time"
-
- bdagent "github.com/unitronix/betterdesk-agent/agent"
- pb "github.com/unitronix/betterdesk-server/proto"
-)
-
-// streamSession sends encoded video frames and processes peer input until the connection closes.
-func (h *Host) streamSession(ps *peerSession, codec negotiatedVideoCodec, options *pb.OptionMessage) error {
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- st := newStreamState(codec, options)
- inputDone := make(chan struct{})
- go func() {
- defer close(inputDone)
- h.readPeerInput(ctx, ps, st)
- cancel()
- }()
-
- err := h.streamEncoded(ctx, ps, st)
- cancel()
- <-inputDone
- return err
-}
-
-func (h *Host) readPeerInput(ctx context.Context, ps *peerSession, st *streamState) {
- for {
- select {
- case <-ctx.Done():
- return
- default:
- }
- frame, err := ps.read(60 * time.Second)
- if err != nil {
- if err != io.EOF {
- log.Printf("[signalhost] input read: %v", err)
- }
- return
- }
- h.handlePeerMessage(frame, st)
- }
-}
-
-func (h *Host) streamEncoded(ctx context.Context, ps *peerSession, st *streamState) error {
- var pts int64
- for {
- codec := st.currentCodec()
- wire := codec.wire()
- if wire == wireNone {
- return nil
- }
- plan, ok := resolveEncoderPlan(wire)
- if !ok {
- if wire == wireH264 {
- return h.streamScreenshotFallback(ctx, ps, st)
- }
- log.Printf("[signalhost] no encoder for %s", wire)
- return nil
- }
-
- settings := st.settings()
- strategies := captureStrategies(settings.fps)
- if len(strategies) == 0 {
- if wire == wireH264 {
- return h.streamScreenshotFallback(ctx, ps, st)
- }
- return nil
- }
-
- var started bool
- for _, strat := range strategies {
- args := buildCaptureEncodeArgs(strat, plan, settings.fps, settings.quality)
- if len(args) == 0 {
- continue
- }
- encoderCtx, cancelEncoder := context.WithCancel(ctx)
- cmd, stdout, err := startFFmpegCapture(encoderCtx, args)
- if err != nil {
- cancelEncoder()
- log.Printf("[signalhost] ffmpeg %s/%s: %v", strat.Name, plan.ffmpegName, err)
- continue
- }
- started = true
- st.markEncoderStarted(time.Now())
- log.Printf("[signalhost] streaming codec=%s encoder=%s capture=%s", wire, plan.ffmpegName, strat.Name)
-
- done := make(chan error, 1)
- go func() {
- var writeErr error
- onFrame := func(au []byte, keyframe bool) {
- if writeErr != nil {
- return
- }
- startedAt := time.Now()
- if err := ps.write(videoFrameMessage(wire, au, keyframe, pts)); err != nil {
- writeErr = err
- cancelEncoder()
- return
- }
- pts++
- st.observeWrite(time.Since(startedAt))
- }
- switch plan.mode {
- case frameModeIVF:
- readIVFFrames(encoderCtx, stdout, wire, onFrame)
- default:
- if wire == wireH265 {
- readHEVCAnnexBFrames(encoderCtx, stdout, onFrame)
- } else {
- readAnnexBFrames(encoderCtx, stdout, onFrame)
- }
- }
- done <- writeErr
- }()
-
- select {
- case <-ctx.Done():
- cancelEncoder()
- <-done
- _ = cmd.Wait()
- return nil
- case <-st.reconfigure:
- cancelEncoder()
- writeErr := <-done
- _ = cmd.Wait()
- if writeErr != nil {
- return writeErr
- }
- if ctx.Err() != nil {
- return nil
- }
- goto restart
- case writeErr := <-done:
- cancelEncoder()
- waitErr := cmd.Wait()
- if writeErr != nil {
- return writeErr
- }
- if ctx.Err() != nil {
- return nil
- }
- if waitErr != nil {
- log.Printf("[signalhost] %s capture ended (%s): %v", wire, strat.Name, waitErr)
- }
- // try next capture strategy
- }
- }
- if !started && wire == wireH264 {
- return h.streamScreenshotFallback(ctx, ps, st)
- }
- if !started {
- return nil
- }
- // All strategies failed mid-stream — H.264 can fall back to screenshots.
- if wire == wireH264 {
- return h.streamScreenshotFallback(ctx, ps, st)
- }
- select {
- case <-ctx.Done():
- return nil
- case <-time.After(2 * time.Second):
- }
- restart:
- }
-}
-
-func (h *Host) streamScreenshotFallback(ctx context.Context, ps *peerSession, st *streamState) error {
- var pts int64
- for {
- if st.currentCodec() != videoCodecH264 {
- return nil
- }
- settings := st.settings()
- timer := time.NewTimer(frameInterval(settings.fps))
- select {
- case <-ctx.Done():
- if !timer.Stop() {
- select {
- case <-timer.C:
- default:
- }
- }
- return nil
- case <-st.reconfigure:
- if !timer.Stop() {
- select {
- case <-timer.C:
- default:
- }
- }
- continue
- case <-timer.C:
- }
-
- jpeg, err := bdagent.CaptureScreenshotJPEG()
- if err != nil || len(jpeg) == 0 {
- continue
- }
- au, key, encErr := encodeJPEGToH264(ctx, jpeg, settings.quality)
- if encErr != nil || len(au) == 0 {
- select {
- case <-ctx.Done():
- return nil
- case <-time.After(2 * time.Second):
- }
- continue
- }
- started := time.Now()
- if err := ps.write(videoFrameMessage(wireH264, au, key, pts)); err != nil {
- return err
- }
- pts++
- st.observeWrite(time.Since(started))
- }
-}
-
-// readAnnexBFrames splits H.264 Annex-B stream into access units.
-func readAnnexBFrames(ctx context.Context, r io.Reader, onFrame func(au []byte, keyframe bool)) {
- readAnnexB(ctx, r, false, onFrame)
-}
-
-func readHEVCAnnexBFrames(ctx context.Context, r io.Reader, onFrame func(au []byte, keyframe bool)) {
- readAnnexB(ctx, r, true, onFrame)
-}
-
-func readAnnexB(ctx context.Context, r io.Reader, hevc bool, onFrame func(au []byte, keyframe bool)) {
- buf := make([]byte, 0, 512*1024)
- tmp := make([]byte, 65536)
- var au []byte
- auHasVCL := false
- auIsKey := false
-
- flush := func() {
- if len(au) > 0 && auHasVCL {
- out := make([]byte, len(au))
- copy(out, au)
- onFrame(out, auIsKey)
- }
- au = au[:0]
- auHasVCL = false
- auIsKey = false
- }
-
- for {
- select {
- case <-ctx.Done():
- flush()
- return
- default:
- }
- n, readErr := r.Read(tmp)
- if n > 0 {
- buf = append(buf, tmp[:n]...)
- }
- for {
- s := indexStartCode(buf, 0)
- if s < 0 {
- break
- }
- scLen := startCodeLen(buf, s)
- next := indexStartCode(buf, s+scLen)
- if next < 0 {
- if s > 0 {
- buf = buf[s:]
- }
- break
- }
- nal := buf[s:next]
- if hevc {
- nalType := byte(0)
- if s+scLen < next {
- nalType = (nal[scLen] >> 1) & 0x3F
- }
- switch nalType {
- case 19, 20, 21: // IDR / CRA
- if auHasVCL {
- flush()
- }
- au = append(au, nal...)
- auHasVCL = true
- auIsKey = true
- case 0, 1: // TRAIL
- if auHasVCL {
- flush()
- }
- au = append(au, nal...)
- auHasVCL = true
- case 32, 33, 34, 39: // VPS/SPS/PPS/SEI
- au = append(au, nal...)
- default:
- if auHasVCL {
- flush()
- }
- au = append(au, nal...)
- }
- } else {
- nalType := byte(0)
- if s+scLen < next {
- nalType = nal[scLen] & 0x1F
- }
- switch nalType {
- case 1:
- if auHasVCL {
- flush()
- }
- au = append(au, nal...)
- auHasVCL = true
- case 5:
- if auHasVCL {
- flush()
- }
- au = append(au, nal...)
- auHasVCL = true
- auIsKey = true
- case 7, 8, 6:
- au = append(au, nal...)
- default:
- if auHasVCL {
- flush()
- }
- au = append(au, nal...)
- }
- }
- buf = buf[next:]
- }
- if readErr != nil {
- flush()
- return
- }
- }
-}
-
-func indexStartCode(buf []byte, from int) int {
- for i := from; i+3 < len(buf); i++ {
- if buf[i] == 0 && buf[i+1] == 0 {
- if buf[i+2] == 1 {
- return i
- }
- if i+3 < len(buf) && buf[i+2] == 0 && buf[i+3] == 1 {
- return i
- }
- }
- }
- return -1
-}
-
-func startCodeLen(buf []byte, at int) int {
- if at+3 < len(buf) && buf[at+2] == 1 {
- return 3
- }
- return 4
-}
diff --git a/betterdesk-support-agent/signalhost/stream_build.go b/betterdesk-support-agent/signalhost/stream_build.go
deleted file mode 100644
index 8857eb7b..00000000
--- a/betterdesk-support-agent/signalhost/stream_build.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package signalhost
-
-// buildCaptureEncodeArgs assembles ffmpeg argv: capture input + encoder plan.
-func buildCaptureEncodeArgs(capture captureStrategy, plan encoderPlan, fps, quality int) []string {
- if len(capture.Args) == 0 || plan.ffmpegName == "" {
- return nil
- }
- args := []string{"-hide_banner", "-loglevel", "error"}
- args = append(args, plan.preInputArgs()...)
- args = append(args, capture.Args...)
- args = append(args, plan.encoderTail(fps, quality)...)
- return args
-}
diff --git a/betterdesk-support-agent/signalhost/stream_darwin.go b/betterdesk-support-agent/signalhost/stream_darwin.go
deleted file mode 100644
index 0753b4b6..00000000
--- a/betterdesk-support-agent/signalhost/stream_darwin.go
+++ /dev/null
@@ -1,5 +0,0 @@
-//go:build darwin
-
-package signalhost
-
-// Darwin capture strategies live in capture_darwin.go.
diff --git a/betterdesk-support-agent/signalhost/stream_fuzz_test.go b/betterdesk-support-agent/signalhost/stream_fuzz_test.go
deleted file mode 100644
index 998c9093..00000000
--- a/betterdesk-support-agent/signalhost/stream_fuzz_test.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package signalhost
-
-import (
- "bytes"
- "context"
- "testing"
-)
-
-const maxAnnexBFuzzInput = 128 * 1024
-
-// FuzzAnnexBFraming checks that malformed H.264 Annex-B input cannot panic or
-// make the bounded stream splitter emit a frame larger than its input.
-func FuzzAnnexBFraming(f *testing.F) {
- f.Add([]byte{})
- f.Add([]byte{
- 0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1f,
- 0x00, 0x00, 0x01, 0x65, 0x88, 0x84,
- 0x00, 0x00, 0x01, 0x61, 0x9a,
- })
-
- f.Fuzz(func(t *testing.T, data []byte) {
- if len(data) > maxAnnexBFuzzInput {
- t.Skip()
- }
-
- readAnnexBFrames(context.Background(), bytes.NewReader(data), func(au []byte, _ bool) {
- if len(au) > len(data) {
- t.Fatalf("Annex-B frame length = %d, input length = %d", len(au), len(data))
- }
- })
- })
-}
diff --git a/betterdesk-support-agent/signalhost/stream_linux.go b/betterdesk-support-agent/signalhost/stream_linux.go
deleted file mode 100644
index 5ace84fd..00000000
--- a/betterdesk-support-agent/signalhost/stream_linux.go
+++ /dev/null
@@ -1,5 +0,0 @@
-//go:build linux
-
-package signalhost
-
-// Linux capture strategies live in capture_linux.go.
diff --git a/betterdesk-support-agent/signalhost/stream_linux_test.go b/betterdesk-support-agent/signalhost/stream_linux_test.go
deleted file mode 100644
index 0583a383..00000000
--- a/betterdesk-support-agent/signalhost/stream_linux_test.go
+++ /dev/null
@@ -1,33 +0,0 @@
-//go:build linux
-
-package signalhost
-
-import "testing"
-
-func TestLinuxCaptureUsesInheritedX11Display(t *testing.T) {
- t.Setenv("DISPLAY", ":42")
-
- strats := platformCaptureStrategies(15)
- if len(strats) == 0 {
- t.Fatal("expected X11 capture strategy")
- }
- found := false
- for _, a := range strats[0].Args {
- if a == ":42" {
- found = true
- }
- }
- if !found {
- t.Fatalf("capture args = %#v, want display :42", strats[0].Args)
- }
-}
-
-func TestLinuxCaptureDoesNotGuessDisplayOnPureWayland(t *testing.T) {
- t.Setenv("WAYLAND_DISPLAY", "wayland-0")
- t.Setenv("XDG_SESSION_TYPE", "wayland")
- t.Setenv("DISPLAY", "")
-
- if strats := platformCaptureStrategies(15); strats != nil {
- t.Fatalf("platformCaptureStrategies() = %#v, want nil screenshot fallback", strats)
- }
-}
diff --git a/betterdesk-support-agent/signalhost/stream_other.go b/betterdesk-support-agent/signalhost/stream_other.go
deleted file mode 100644
index e8e639ad..00000000
--- a/betterdesk-support-agent/signalhost/stream_other.go
+++ /dev/null
@@ -1,5 +0,0 @@
-//go:build !linux && !windows && !darwin
-
-package signalhost
-
-// Capture strategies for other GOOS live in capture_other.go.
diff --git a/betterdesk-support-agent/signalhost/stream_windows.go b/betterdesk-support-agent/signalhost/stream_windows.go
deleted file mode 100644
index 4e1c594b..00000000
--- a/betterdesk-support-agent/signalhost/stream_windows.go
+++ /dev/null
@@ -1,5 +0,0 @@
-//go:build windows
-
-package signalhost
-
-// Windows capture strategies live in capture_windows.go.
diff --git a/betterdesk-support-agent/signalhost/video_frames.go b/betterdesk-support-agent/signalhost/video_frames.go
deleted file mode 100644
index 88158332..00000000
--- a/betterdesk-support-agent/signalhost/video_frames.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package signalhost
-
-import pb "github.com/unitronix/betterdesk-server/proto"
-
-func videoFrameMessage(wire string, data []byte, key bool, pts int64) *pb.Message {
- frames := &pb.EncodedVideoFrames{
- Frames: []*pb.EncodedVideoFrame{{Data: data, Key: key, Pts: pts}},
- }
- vf := &pb.VideoFrame{Display: 0}
- switch wire {
- case wireH265:
- vf.Union = &pb.VideoFrame_H265S{H265S: frames}
- case wireVP8:
- vf.Union = &pb.VideoFrame_Vp8S{Vp8S: frames}
- case wireVP9:
- vf.Union = &pb.VideoFrame_Vp9S{Vp9S: frames}
- case wireAV1:
- vf.Union = &pb.VideoFrame_Av1S{Av1S: frames}
- default:
- vf.Union = &pb.VideoFrame_H264S{H264S: frames}
- }
- return &pb.Message{Union: &pb.Message_VideoFrame{VideoFrame: vf}}
-}
diff --git a/betterdesk-support-agent/state.go b/betterdesk-support-agent/state.go
deleted file mode 100644
index 7dff65dc..00000000
--- a/betterdesk-support-agent/state.go
+++ /dev/null
@@ -1,420 +0,0 @@
-package main
-
-import (
- "crypto/rand"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "sync"
-)
-
-// AccessMode controls how inbound remote-desktop sessions are authorized.
-const (
- // AccessSupervised prompts the local user to accept each session.
- AccessSupervised = "supervised"
- // AccessUnattended lets the operator connect with the access password,
- // no local prompt.
- AccessUnattended = "unattended"
- // AccessDisabled refuses every inbound desktop session.
- AccessDisabled = "disabled"
-)
-
-// AppState is the support agent's local persistent state. It is distinct from
-// the baked Branding (connection + appearance): it stores only per-device,
-// user-mutable settings that must survive restarts.
-//
-// The state file is encrypted at rest with a machine-bound key (see crypto.go)
-// so the device identity and access password cannot be read directly from disk
-// or copied to another machine to impersonate this device. The access password
-// is still exposed to the local user through the UI, where it is decrypted in
-// memory on demand. The file is additionally written with 0600 permissions.
-type AppState struct {
- DeviceID string `json:"device_id"`
- InstallationSecret string `json:"installation_secret,omitempty"`
- MachineUUID string `json:"machine_uuid,omitempty"`
- AccessMode string `json:"access_mode"`
- AccessPassword string `json:"access_password"`
- CustomPassword bool `json:"custom_password"`
- Language string `json:"language"`
- TOTPEnabled bool `json:"totp_enabled,omitempty"`
- TOTPSecret string `json:"totp_secret,omitempty"`
- DeviceToken string `json:"device_token,omitempty"`
- EnrollmentStatus string `json:"enrollment_status,omitempty"`
- EnrollmentMessage string `json:"enrollment_message,omitempty"`
- // Last-known-good connection endpoints (transport resilience).
- LastGoodCDAP string `json:"last_good_cdap,omitempty"`
- LastGoodAPI string `json:"last_good_api,omitempty"`
- LastGoodAt string `json:"last_good_at,omitempty"`
-
- mu sync.Mutex `json:"-"`
- path string `json:"-"`
-}
-
-// stateDir returns the directory holding the persistent state file. Portable
-// builds keep state in a writable location (USB-friendly tar/portable binary,
-// or beside the .AppImage file). Detection order:
-// 1. BETTERDESK_AGENT_DATA_DIR env override
-// 2. AppImage: APPIMAGE env → betterdesk-support-data/ next to the .AppImage
-// 3. portable marker ("portable" or ".portable") next to the executable → data/
-// 4. default per-user config directory
-func stateDir() string {
- if d := os.Getenv("BETTERDESK_AGENT_DATA_DIR"); d != "" {
- return d
- }
- if dir, ok := portableDataDir(); ok {
- return dir
- }
- base, err := os.UserConfigDir()
- if err != nil || base == "" {
- base, _ = os.UserHomeDir()
- base = filepath.Join(base, ".config")
- }
- return filepath.Join(base, "betterdesk-support")
-}
-
-// portableDataDir reports a writable data directory for portable distributions.
-func portableDataDir() (string, bool) {
- if dir, ok := appImagePortableDir(); ok {
- return dir, true
- }
- exe, err := os.Executable()
- if err != nil {
- return "", false
- }
- dir := filepath.Dir(exe)
- for _, marker := range []string{"portable", ".portable"} {
- if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
- return filepath.Join(dir, "data"), true
- }
- }
- return "", false
-}
-
-// appImagePortableDir stores state beside the .AppImage on disk. The runtime
-// mount at usr/bin/ is read-only, so data cannot live next to the binary.
-func appImagePortableDir() (string, bool) {
- appImage := strings.TrimSpace(os.Getenv("APPIMAGE"))
- if appImage == "" {
- return "", false
- }
- return filepath.Join(filepath.Dir(appImage), "betterdesk-support-data"), true
-}
-
-// IsPortable reports portable distribution (AppImage, or marker beside binary).
-func IsPortable() bool {
- if os.Getenv("BETTERDESK_AGENT_DATA_DIR") != "" {
- return false
- }
- _, ok := portableDataDir()
- return ok
-}
-
-// LoadState reads the persistent state, creating defaults (including a stable
-// device ID and a random access password) on first run.
-func LoadState() (*AppState, error) {
- dir := stateDir()
- if err := os.MkdirAll(dir, 0o700); err != nil {
- return nil, fmt.Errorf("create state dir: %w", err)
- }
- path := filepath.Join(dir, "state.json")
-
- s := &AppState{path: path}
- legacyPlaintext := false
- if data, err := os.ReadFile(path); err == nil {
- plain, derr := loadStateBytes(data)
- if derr == nil {
- _ = json.Unmarshal(plain, s)
- legacyPlaintext = !isEncryptedState(data)
- }
- // On decrypt failure (file from another machine or corrupted) the
- // fields stay zero and are regenerated below — a copied identity is
- // never reused, which is the anti-impersonation guarantee.
- s.path = path
- } else if !os.IsNotExist(err) {
- return nil, fmt.Errorf("read state: %w", err)
- }
-
- changed := legacyPlaintext // force re-encryption of legacy files
- if s.InstallationSecret == "" {
- s.InstallationSecret = newInstallationSecret()
- changed = true
- }
- if s.MachineUUID == "" {
- if s.DeviceID != "" {
- // Preserve uuid anchor for devices enrolled before v2 fingerprinting.
- s.MachineUUID = legacyMachineSeed()
- if s.MachineUUID == "" {
- s.MachineUUID = machineFingerprint()
- }
- } else {
- s.MachineUUID = machineFingerprint()
- }
- changed = true
- }
- if s.DeviceID == "" {
- s.DeviceID = generateDeviceID(s.InstallationSecret)
- changed = true
- }
- if s.AccessMode == "" {
- s.AccessMode = AccessSupervised
- changed = true
- }
- if s.AccessPassword == "" {
- s.AccessPassword = randomPassword()
- s.CustomPassword = false
- changed = true
- }
- if s.Language == "" {
- s.Language = resolveInitialLanguage(GetBranding().DefaultLanguage)
- changed = true
- } else {
- s.Language = normalizeLocale(s.Language)
- }
- if changed {
- if err := s.save(); err != nil {
- return nil, err
- }
- }
- return s, nil
-}
-
-// save persists the state atomically with 0600 permissions, encrypted with the
-// machine-bound key. Caller must hold the mutex (or be in a single-threaded
-// init path).
-func (s *AppState) save() error {
- data, err := json.Marshal(s)
- if err != nil {
- return err
- }
- blob, err := encryptState(data)
- if err != nil {
- return fmt.Errorf("encrypt state: %w", err)
- }
- tmp := s.path + ".tmp"
- if err := os.WriteFile(tmp, blob, 0o600); err != nil {
- return err
- }
- return os.Rename(tmp, s.path)
-}
-
-// loadStateBytes returns the decrypted JSON for a stored state file. Encrypted
-// files are opened with the machine key; legacy plaintext JSON (written by
-// older builds) is accepted once and re-encrypted on the next save.
-func loadStateBytes(data []byte) ([]byte, error) {
- if isEncryptedState(data) {
- return decryptState(data)
- }
- if len(data) > 0 && (data[0] == '{' || data[0] == '[') {
- return data, nil // legacy plaintext, migrate on next save
- }
- return nil, fmt.Errorf("unrecognized state format")
-}
-
-// SetAccessMode updates the access policy and persists it.
-func (s *AppState) SetAccessMode(mode string) error {
- switch mode {
- case AccessSupervised, AccessUnattended, AccessDisabled:
- default:
- return fmt.Errorf("invalid access mode: %q", mode)
- }
- s.mu.Lock()
- defer s.mu.Unlock()
- s.AccessMode = mode
- return s.save()
-}
-
-// SetCustomPassword stores a user-chosen access password. An empty value
-// reverts to a freshly generated random password.
-func (s *AppState) SetCustomPassword(pw string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- pw = strings.TrimSpace(pw)
- if pw == "" {
- s.AccessPassword = randomPassword()
- s.CustomPassword = false
- } else {
- if len(pw) < 6 {
- return fmt.Errorf("password must be at least 6 characters")
- }
- s.AccessPassword = pw
- s.CustomPassword = true
- }
- return s.save()
-}
-
-// RegeneratePassword replaces the access password with a fresh random one.
-func (s *AppState) RegeneratePassword() error {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.AccessPassword = randomPassword()
- s.CustomPassword = false
- return s.save()
-}
-
-// SetLanguage persists the UI language preference.
-func (s *AppState) SetLanguage(lang string) error {
- lang = normalizeLocale(lang)
- if !hasLocale(lang) {
- lang = "en"
- }
- s.mu.Lock()
- defer s.mu.Unlock()
- s.Language = lang
- return s.save()
-}
-
-// SetTOTP persists local 2FA state used by the relay host.
-func (s *AppState) SetTOTP(enabled bool, secret string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.TOTPEnabled = enabled
- s.TOTPSecret = secret
- return s.save()
-}
-
-// TOTPSnapshot returns device 2FA settings.
-func (s *AppState) TOTPSnapshot() (enabled bool, secret string) {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.TOTPEnabled, s.TOTPSecret
-}
-
-// Snapshot returns a copy of the user-facing fields without exposing the mutex.
-func (s *AppState) Snapshot() (deviceID, mode, password string, custom bool) {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.DeviceID, s.AccessMode, s.AccessPassword, s.CustomPassword
-}
-
-// EnrollmentSnapshot returns enrollment-related fields.
-func (s *AppState) EnrollmentSnapshot() (status, token, message string) {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.EnrollmentStatus, s.DeviceToken, s.EnrollmentMessage
-}
-
-// SetEnrollment persists enrollment outcome and optional device token.
-func (s *AppState) SetEnrollment(status, deviceID, token, message string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- if deviceID != "" {
- s.DeviceID = deviceID
- }
- s.EnrollmentStatus = status
- // Pending and rejected outcomes are authoritative non-approved states.
- // Retaining a previous credential here would let later enrollment
- // requests keep presenting a token the server has revoked or rejected.
- if status == EnrollmentPending || status == EnrollmentRejected {
- s.DeviceToken = ""
- } else if token != "" {
- s.DeviceToken = token
- }
- s.EnrollmentMessage = message
- return s.save()
-}
-
-// SetEnrollmentMessage updates the pending/rejected message only.
-func (s *AppState) SetEnrollmentMessage(message string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.EnrollmentMessage = message
- return s.save()
-}
-
-// ResetEnrollmentState clears enrollment fields so the next run registers fresh.
-func ResetEnrollmentState() error {
- st, err := LoadState()
- if err != nil {
- return err
- }
- st.mu.Lock()
- st.EnrollmentStatus = ""
- st.DeviceToken = ""
- st.EnrollmentMessage = ""
- st.mu.Unlock()
- return st.save()
-}
-
-// IsEnrolled reports whether the device has an approved token.
-func (s *AppState) IsEnrolled() bool {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.EnrollmentStatus == EnrollmentApproved && s.DeviceToken != ""
-}
-
-// GetMachineUUID returns the fingerprint sent to the server during enrollment.
-func (s *AppState) GetMachineUUID() string {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.MachineUUID
-}
-
-// SetDeviceID updates the device identifier after a server-suggested collision
-// resolution and clears enrollment so the agent re-registers.
-func (s *AppState) SetDeviceID(id string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.DeviceID = id
- s.EnrollmentStatus = ""
- s.DeviceToken = ""
- s.EnrollmentMessage = ""
- return s.save()
-}
-
-func newInstallationSecret() string {
- buf := make([]byte, 32)
- if _, err := rand.Read(buf); err != nil {
- return hex.EncodeToString([]byte("betterdesk-fallback-secret"))
- }
- return hex.EncodeToString(buf)
-}
-
-// generateDeviceID derives a stable per-installation identifier. The
-// installation secret ensures two fresh installs on the same hardware get
-// different IDs; the machine fingerprint anchors uuid sent to the server.
-func generateDeviceID(installationSecret string) string {
- fp := machineFingerprint()
- if fp == "" {
- fp = legacyMachineSeed()
- }
- if fp == "" {
- buf := make([]byte, 8)
- _, _ = rand.Read(buf)
- fp = hex.EncodeToString(buf)
- }
- material := "betterdesk-support-v2|" + fp + "|" + installationSecret
- sum := sha256.Sum256([]byte(material))
- return "BD-" + strings.ToUpper(hex.EncodeToString(sum[:5]))
-}
-
-// deriveDeviceIDWithSuffix appends a server-suggested suffix for collision resolution.
-func deriveDeviceIDWithSuffix(baseID, suffix string) string {
- baseID = strings.TrimSpace(baseID)
- suffix = strings.TrimSpace(suffix)
- if baseID == "" || suffix == "" {
- return baseID
- }
- if strings.HasSuffix(baseID, suffix) {
- return baseID
- }
- return baseID + suffix
-}
-
-// randomPassword returns a short, human-typable access password.
-func randomPassword() string {
- const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
- const n = 8
- buf := make([]byte, n)
- if _, err := rand.Read(buf); err != nil {
- return "betterdesk"
- }
- out := make([]byte, n)
- for i, b := range buf {
- out[i] = alphabet[int(b)%len(alphabet)]
- }
- return string(out)
-}
diff --git a/betterdesk-support-agent/state_test.go b/betterdesk-support-agent/state_test.go
deleted file mode 100644
index 04feaf75..00000000
--- a/betterdesk-support-agent/state_test.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package main
-
-import (
- "path/filepath"
- "testing"
-)
-
-func TestAppImagePortableDir(t *testing.T) {
- t.Setenv("APPIMAGE", "/home/user/Downloads/BetterDesk.AppImage")
- dir, ok := appImagePortableDir()
- if !ok {
- t.Fatal("expected AppImage portable dir")
- }
- want := filepath.Join("/home/user/Downloads", "betterdesk-support-data")
- if dir != want {
- t.Fatalf("got %q want %q", dir, want)
- }
-}
-
-func TestIsPortableAppImage(t *testing.T) {
- t.Setenv("APPIMAGE", "/tmp/test.AppImage")
- t.Setenv("BETTERDESK_AGENT_DATA_DIR", "")
- if !IsPortable() {
- t.Fatal("AppImage should be treated as portable")
- }
-}
-
-func TestIsPortableNotWhenDataDirOverride(t *testing.T) {
- t.Setenv("APPIMAGE", "/tmp/test.AppImage")
- t.Setenv("BETTERDESK_AGENT_DATA_DIR", "/var/lib/test")
- if IsPortable() {
- t.Fatal("BETTERDESK_AGENT_DATA_DIR override disables portable tagging")
- }
-}
-
-func TestAppImagePortableDirUnset(t *testing.T) {
- t.Setenv("APPIMAGE", "")
- if _, ok := appImagePortableDir(); ok {
- t.Fatal("expected false without APPIMAGE")
- }
-}
diff --git a/betterdesk-support-agent/status.go b/betterdesk-support-agent/status.go
deleted file mode 100644
index 9bb86f1b..00000000
--- a/betterdesk-support-agent/status.go
+++ /dev/null
@@ -1,5 +0,0 @@
-package main
-
-import "time"
-
-const enrollmentRevalidationInterval = time.Minute
diff --git a/betterdesk-support-agent/status_fyne.go b/betterdesk-support-agent/status_fyne.go
deleted file mode 100644
index 4c3d0cb5..00000000
--- a/betterdesk-support-agent/status_fyne.go
+++ /dev/null
@@ -1,14 +0,0 @@
-//go:build fyneui
-
-package main
-
-func (u *ui) applyStatus(kind statusKind, text string) {
- text = shortenStatusText(text, 160)
- if u.statusLbl != nil {
- u.statusLbl.SetText(text)
- }
- if u.statusDot != nil {
- u.statusDot.FillColor = statusColor(kind, u.brand)
- u.statusDot.Refresh()
- }
-}
diff --git a/betterdesk-support-agent/status_loop_fyne.go b/betterdesk-support-agent/status_loop_fyne.go
deleted file mode 100644
index 0f03c0ab..00000000
--- a/betterdesk-support-agent/status_loop_fyne.go
+++ /dev/null
@@ -1,70 +0,0 @@
-//go:build fyneui
-
-package main
-
-import "time"
-
-// startStatusLoop polls the engine connection state and refreshes the status label.
-func (u *ui) startStatusLoop() {
- go func() {
- ticker := time.NewTicker(3 * time.Second)
- defer ticker.Stop()
- var lastEnrollmentCheck time.Time
- for range ticker.C {
- u.updateStatus()
- if !lastEnrollmentCheck.IsZero() &&
- time.Since(lastEnrollmentCheck) < enrollmentRevalidationInterval {
- continue
- }
- status, _, _ := u.state.EnrollmentSnapshot()
- if status != EnrollmentApproved || !u.brand.HasConnection() {
- continue
- }
- lastEnrollmentCheck = time.Now()
- go u.revalidateEnrollment()
- }
- }()
-}
-
-func (u *ui) revalidateEnrollment() {
- result, err := PollEnrollment(u.brand, u.state, version)
- if err != nil {
- return
- }
- if result.Status != EnrollmentApproved {
- u.onEnrollmentUpdate(result)
- }
-}
-
-func (u *ui) updateStatus() {
- if u.statusLbl == nil {
- return
- }
- if !u.brand.HasConnection() {
- u.applyStatus(statusKindReady, t("status_ready"))
- return
- }
- status, _, _ := u.state.EnrollmentSnapshot()
- switch status {
- case EnrollmentPending:
- u.applyStatus(statusKindPending, t("enrollment_pending"))
- return
- case EnrollmentRejected:
- u.applyStatus(statusKindError, t("enrollment_rejected"))
- return
- case EnrollmentApproved:
- if u.state.IsEnrolled() && u.engine.Running() {
- u.applyStatus(statusKindConnected, t("connected"))
- } else if u.state.IsEnrolled() {
- u.applyStatus(statusKindPending, t("disconnected"))
- } else {
- u.applyStatus(statusKindPending, t("enrollment_pending"))
- }
- return
- }
- if u.engine.Running() {
- u.applyStatus(statusKindConnected, t("connected"))
- } else {
- u.applyStatus(statusKindReady, t("status_ready"))
- }
-}
diff --git a/betterdesk-support-agent/status_visual.go b/betterdesk-support-agent/status_visual.go
deleted file mode 100644
index da7a852f..00000000
--- a/betterdesk-support-agent/status_visual.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package main
-
-import (
- "image/color"
- "strings"
-)
-
-type statusKind int
-
-const (
- statusKindReady statusKind = iota
- statusKindPending
- statusKindConnected
- statusKindError
-)
-
-func shortenStatusText(s string, maxRunes int) string {
- s = strings.TrimSpace(s)
- if maxRunes <= 0 {
- return s
- }
- r := []rune(s)
- if len(r) <= maxRunes {
- return s
- }
- return string(r[:maxRunes-1]) + "…"
-}
-
-func statusColor(kind statusKind, b Branding) color.Color {
- switch kind {
- case statusKindConnected, statusKindReady:
- return parseHexColor(b.StatusReadyColor, color.RGBA{R: 0x22, G: 0xc5, B: 0x5e, A: 0xff})
- case statusKindPending:
- return color.RGBA{R: 0xf5, G: 0x9e, B: 0x0b, A: 0xff}
- case statusKindError:
- return color.RGBA{R: 0xef, G: 0x44, B: 0x44, A: 0xff}
- default:
- return parseHexColor(b.StatusReadyColor, color.RGBA{R: 0x22, G: 0xc5, B: 0x5e, A: 0xff})
- }
-}
diff --git a/betterdesk-support-agent/theme.go b/betterdesk-support-agent/theme.go
deleted file mode 100644
index bdb6a54a..00000000
--- a/betterdesk-support-agent/theme.go
+++ /dev/null
@@ -1,55 +0,0 @@
-//go:build fyneui
-
-package main
-
-import (
- "image/color"
-
- "fyne.io/fyne/v2"
- "fyne.io/fyne/v2/theme"
-)
-
-type brandedTheme struct {
- primary color.Color
- accent color.Color
- background color.Color
- surface color.Color
- text color.Color
- textMuted color.Color
- base fyne.Theme
-}
-
-func newBrandedTheme(b Branding) fyne.Theme {
- d := brandingDefaults()
- return &brandedTheme{
- primary: parseHexColor(b.PrimaryColor, mustRGBA(d.PrimaryColor)),
- accent: parseHexColor(b.AccentColor, mustRGBA(d.AccentColor)),
- background: parseHexColor(b.BackgroundColor, mustRGBA(d.BackgroundColor)),
- surface: parseHexColor(b.SurfaceColor, mustRGBA(d.SurfaceColor)),
- text: parseHexColor(b.TextColor, mustRGBA(d.TextColor)),
- textMuted: parseHexColor(b.TextMutedColor, mustRGBA(d.TextMutedColor)),
- base: theme.DefaultTheme(),
- }
-}
-
-func (t *brandedTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
- switch name {
- case theme.ColorNamePrimary, theme.ColorNameHyperlink, theme.ColorNameFocus:
- return t.primary
- case theme.ColorNameSelection:
- return t.accent
- case theme.ColorNameBackground:
- return t.background
- case theme.ColorNameInputBackground:
- return t.surface
- case theme.ColorNameForeground:
- return t.text
- case theme.ColorNameDisabled:
- return t.textMuted
- }
- return t.base.Color(name, variant)
-}
-
-func (t *brandedTheme) Font(style fyne.TextStyle) fyne.Resource { return t.base.Font(style) }
-func (t *brandedTheme) Icon(name fyne.ThemeIconName) fyne.Resource { return t.base.Icon(name) }
-func (t *brandedTheme) Size(name fyne.ThemeSizeName) float32 { return t.base.Size(name) }
diff --git a/betterdesk-support-agent/totp_ui.go b/betterdesk-support-agent/totp_ui.go
deleted file mode 100644
index c2ca0663..00000000
--- a/betterdesk-support-agent/totp_ui.go
+++ /dev/null
@@ -1,144 +0,0 @@
-//go:build fyneui
-
-package main
-
-import (
- "fmt"
- "net/http"
-
- "fyne.io/fyne/v2/container"
- "fyne.io/fyne/v2/dialog"
- "fyne.io/fyne/v2/widget"
-)
-
-type deviceTOTPStatus struct {
- Enabled bool `json:"enabled"`
- URI string `json:"otpauth_uri,omitempty"`
- Secret string `json:"secret,omitempty"`
-}
-
-func (u *ui) fetchTOTPStatus() (deviceTOTPStatus, error) {
- st := u.state
- st.mu.Lock()
- deviceID := st.DeviceID
- token := st.DeviceToken
- st.mu.Unlock()
- var resp deviceTOTPStatus
- code, err := apiJSON(http.MethodPost, apiBaseURL(u.brand)+"/devices/self/totp", map[string]string{
- "device_id": deviceID,
- "device_token": token,
- "action": "status",
- }, &resp)
- if err != nil {
- return resp, err
- }
- if code != http.StatusOK {
- return resp, fmt.Errorf("HTTP %d", code)
- }
- return resp, nil
-}
-
-func (u *ui) showTOTPSettings() {
- status, err := u.fetchTOTPStatus()
- if err != nil {
- u.notify(err.Error())
- return
- }
- statusLbl := widget.NewLabel("")
- if status.Enabled {
- statusLbl.SetText(t("totp_enabled"))
- } else {
- statusLbl.SetText(t("totp_disabled"))
- }
- enableBtn := widget.NewButton(t("totp_setup"), func() {
- u.setupTOTP()
- })
- disableBtn := widget.NewButton(t("totp_disable"), func() {
- u.disableTOTP()
- })
- if status.Enabled {
- enableBtn.Hide()
- } else {
- disableBtn.Hide()
- }
- body := container.NewVBox(statusLbl, enableBtn, disableBtn)
- d := dialog.NewCustom(t("totp_title"), t("close"), body, u.win)
- d.Show()
-}
-
-func (u *ui) setupTOTP() {
- st := u.state
- st.mu.Lock()
- payload := map[string]any{
- "device_id": st.DeviceID, "device_token": st.DeviceToken, "action": "setup",
- }
- st.mu.Unlock()
- var resp deviceTOTPStatus
- code, err := apiJSON(http.MethodPost, apiBaseURL(u.brand)+"/devices/self/totp", payload, &resp)
- if err != nil || code != http.StatusOK {
- u.notify(t("totp_setup_failed"))
- return
- }
- _ = u.state.SetTOTP(false, resp.Secret)
- secretLbl := widget.NewLabel(resp.Secret)
- uriLbl := widget.NewLabel(resp.URI)
- codeEntry := widget.NewEntry()
- codeEntry.SetPlaceHolder(t("totp_enter_code"))
- dialog.NewCustomConfirm(t("totp_title"), t("totp_verify_enable"), t("cancel"),
- container.NewVBox(
- widget.NewLabel(t("totp_manual_key")),
- secretLbl,
- widget.NewLabel(t("totp_step2")),
- uriLbl,
- codeEntry,
- ), func(ok bool) {
- if !ok {
- return
- }
- u.enableTOTP(codeEntry.Text)
- }, u.win).Show()
-}
-
-func (u *ui) enableTOTP(code string) {
- st := u.state
- st.mu.Lock()
- payload := map[string]any{
- "device_id": st.DeviceID, "device_token": st.DeviceToken,
- "action": "enable", "code": code,
- }
- st.mu.Unlock()
- var resp deviceTOTPStatus
- httpCode, err := apiJSON(http.MethodPost, apiBaseURL(u.brand)+"/devices/self/totp", payload, &resp)
- if err != nil || httpCode != http.StatusOK {
- u.notify(t("totp_invalid_code"))
- return
- }
- _, secret := u.state.TOTPSnapshot()
- _ = u.state.SetTOTP(true, secret)
- u.notify(t("totp_enabled_success"))
-}
-
-func (u *ui) disableTOTP() {
- entry := widget.NewEntry()
- entry.SetPlaceHolder(t("totp_enter_code"))
- dialog.NewCustomConfirm(t("totp_disable"), t("save"), t("cancel"), entry, func(ok bool) {
- if !ok {
- return
- }
- st := u.state
- st.mu.Lock()
- payload := map[string]any{
- "device_id": st.DeviceID, "device_token": st.DeviceToken,
- "action": "disable", "code": entry.Text,
- }
- st.mu.Unlock()
- var resp deviceTOTPStatus
- httpCode, err := apiJSON(http.MethodPost, apiBaseURL(u.brand)+"/devices/self/totp", payload, &resp)
- if err != nil || httpCode != http.StatusOK {
- u.notify(t("totp_invalid_code"))
- return
- }
- _ = u.state.SetTOTP(false, "")
- u.notify(t("totp_disabled_success"))
- }, u.win).Show()
-}
diff --git a/betterdesk-support-agent/transport_prefs.go b/betterdesk-support-agent/transport_prefs.go
deleted file mode 100644
index 6d0cebad..00000000
--- a/betterdesk-support-agent/transport_prefs.go
+++ /dev/null
@@ -1,157 +0,0 @@
-package main
-
-import (
- "net/url"
- "strings"
- "time"
-)
-
-// RememberGoodEndpoints persists the last-known-good CDAP and API base URLs
-// so the next start prefers a working path after operator config churn.
-func (st *AppState) RememberGoodEndpoints(cdapWS, apiBase string) {
- st.mu.Lock()
- defer st.mu.Unlock()
- changed := false
- if u := strings.TrimSpace(cdapWS); u != "" && u != st.LastGoodCDAP {
- st.LastGoodCDAP = u
- changed = true
- }
- if u := strings.TrimSpace(apiBase); u != "" && u != st.LastGoodAPI {
- st.LastGoodAPI = u
- changed = true
- }
- if changed {
- st.LastGoodAt = time.Now().UTC().Format(time.RFC3339)
- _ = st.save()
- }
-}
-
-func (st *AppState) LastGood() (cdapWS, apiBase string) {
- st.mu.Lock()
- defer st.mu.Unlock()
- return st.LastGoodCDAP, st.LastGoodAPI
-}
-
-// CandidateCDAPWebSockets returns CDAP WS URLs to try, last-good first.
-func CandidateCDAPWebSockets(b Branding, st *AppState) []string {
- var out []string
- seen := map[string]bool{}
- add := func(u string) {
- u = strings.TrimRight(strings.TrimSpace(u), "/")
- if u == "" || seen[u] || !b.allowsEndpoint(u) {
- return
- }
- seen[u] = true
- out = append(out, u)
- }
- if st != nil {
- last, _ := st.LastGood()
- add(last)
- }
- add(b.CDAPWebSocketURL())
- if b.Server != nil {
- add(strings.TrimSpace(b.Server.CDAPURL))
- }
- // A local developer may deliberately test a scheme change. Distributed
- // profiles never derive a plaintext fallback from a signed WSS endpoint.
- if !isReleaseBuild() {
- primary := b.CDAPWebSocketURL()
- if strings.HasPrefix(primary, "wss://") {
- add("ws://" + strings.TrimPrefix(primary, "wss://"))
- } else if strings.HasPrefix(primary, "ws://") {
- add("wss://" + strings.TrimPrefix(primary, "ws://"))
- }
- }
- return out
-}
-
-// CandidateAPIBases returns API base URLs to try, last-good first.
-func CandidateAPIBases(b Branding, st *AppState) []string {
- var out []string
- seen := map[string]bool{}
- add := func(u string) {
- u = strings.TrimRight(strings.TrimSpace(u), "/")
- if u == "" || seen[u] || !b.allowsEndpoint(u) {
- return
- }
- seen[u] = true
- out = append(out, u)
- }
- if st != nil {
- _, last := st.LastGood()
- add(last)
- }
- add(apiBaseURL(b))
- if b.Server != nil {
- add(strings.TrimSpace(b.Server.APIURL))
- }
- return out
-}
-
-func (b Branding) allowsEndpoint(endpoint string) bool {
- if !isReleaseBuild() {
- return true
- }
- endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
- if !isAllowedTransportEndpoint(endpoint) {
- return false
- }
- for _, allowed := range b.AllowedEndpoints {
- if endpoint == strings.TrimRight(strings.TrimSpace(allowed), "/") {
- return true
- }
- }
- return false
-}
-
-// healthURLFromCDAPWS converts a CDAP websocket URL to its /cdap/health HTTP twin.
-func healthURLFromCDAPWS(ws string) string {
- ws = strings.TrimSpace(ws)
- if ws == "" {
- return ""
- }
- u := strings.Replace(ws, "wss://", "https://", 1)
- u = strings.Replace(u, "ws://", "http://", 1)
- u = strings.TrimRight(u, "/")
- if strings.HasSuffix(u, "/cdap") {
- return u + "/health"
- }
- parsed, err := url.Parse(u)
- if err != nil {
- return u + "/cdap/health"
- }
- parsed.Path = "/cdap/health"
- parsed.RawQuery = ""
- parsed.Fragment = ""
- return parsed.String()
-}
-
-// PickWorkingCDAP probes candidates and returns the first healthy WS URL.
-func PickWorkingCDAP(b Branding, st *AppState) (string, ProbeResult) {
- for _, ws := range CandidateCDAPWebSockets(b, st) {
- health := healthURLFromCDAPWS(ws)
- pr := probeHealth(health)
- if pr.OK {
- if st != nil {
- st.RememberGoodEndpoints(ws, "")
- }
- return ws, pr
- }
- }
- // Fall back to branded default even if unhealthy — engine will reconnect.
- return b.CDAPWebSocketURL(), ProbeResult{OK: false, Detail: "no healthy CDAP endpoint"}
-}
-
-// PickWorkingAPI probes candidate API bases.
-func PickWorkingAPI(b Branding, st *AppState) (string, ProbeResult) {
- for _, base := range CandidateAPIBases(b, st) {
- pr := probeHealth(strings.TrimRight(base, "/") + "/health")
- if pr.OK {
- if st != nil {
- st.RememberGoodEndpoints("", base)
- }
- return base, pr
- }
- }
- return apiBaseURL(b), ProbeResult{OK: false, Detail: "no healthy API endpoint"}
-}
diff --git a/betterdesk-support-agent/transport_prefs_release_test.go b/betterdesk-support-agent/transport_prefs_release_test.go
deleted file mode 100644
index 3a0c3f85..00000000
--- a/betterdesk-support-agent/transport_prefs_release_test.go
+++ /dev/null
@@ -1,57 +0,0 @@
-//go:build release
-
-package main
-
-import "testing"
-
-func TestReleaseTransportCandidatesRejectDowngradeAndStaleEndpoints(t *testing.T) {
- brand := Branding{
- AllowedEndpoints: []string{
- "https://desk.example.test/api",
- "wss://desk.example.test:21122/cdap",
- },
- Server: &ServerBranding{
- APIURL: "https://desk.example.test/api",
- CDAPURL: "wss://desk.example.test:21122/cdap",
- },
- }
- state := &AppState{
- LastGoodCDAP: "ws://desk.example.test:21122/cdap",
- LastGoodAPI: "http://desk.example.test/api",
- }
- if got := CandidateCDAPWebSockets(brand, state); len(got) != 1 || got[0] != brand.Server.CDAPURL {
- t.Fatalf("CDAP candidates = %v", got)
- }
- if got := CandidateAPIBases(brand, state); len(got) != 1 || got[0] != brand.Server.APIURL {
- t.Fatalf("API candidates = %v", got)
- }
-}
-
-func TestReleaseTransportCandidatesAllowSignedHTTPProfile(t *testing.T) {
- brand := Branding{
- UseHTTPS: false,
- AllowedEndpoints: []string{
- "http://192.168.0.110:5443",
- "http://192.168.0.110:5443/api",
- "ws://192.168.0.110:21122/cdap",
- },
- Server: &ServerBranding{
- Address: "http://192.168.0.110:5443",
- APIURL: "http://192.168.0.110:5443/api",
- CDAPURL: "ws://192.168.0.110:21122/cdap",
- },
- }
- state := &AppState{
- LastGoodCDAP: "wss://evil.example.test:21122/cdap",
- LastGoodAPI: "https://evil.example.test/api",
- }
- if got := CandidateCDAPWebSockets(brand, state); len(got) != 1 || got[0] != brand.Server.CDAPURL {
- t.Fatalf("CDAP candidates = %v", got)
- }
- if got := CandidateAPIBases(brand, state); len(got) != 1 || got[0] != brand.Server.APIURL {
- t.Fatalf("API candidates = %v", got)
- }
- if !brand.allowsEndpoint(brand.Server.CDAPURL) || !brand.allowsEndpoint(brand.Server.APIURL) {
- t.Fatal("signed HTTP/WS endpoints should be allowed")
- }
-}
diff --git a/betterdesk-support-agent/tray_fyne.go b/betterdesk-support-agent/tray_fyne.go
deleted file mode 100644
index 6b1c1926..00000000
--- a/betterdesk-support-agent/tray_fyne.go
+++ /dev/null
@@ -1,7 +0,0 @@
-//go:build fyneui
-
-package main
-
-// The Fyne shell owns its notification-area integration in ui.setupTray.
-func (s *AppService) startTray() {}
-func (s *AppService) stopTray() {}
diff --git a/betterdesk-support-agent/tray_wails.go b/betterdesk-support-agent/tray_wails.go
deleted file mode 100644
index 0c00e93f..00000000
--- a/betterdesk-support-agent/tray_wails.go
+++ /dev/null
@@ -1,78 +0,0 @@
-//go:build !fyneui
-
-package main
-
-import (
- "bytes"
- "image"
- _ "image/gif"
- _ "image/jpeg"
- "sync"
-
- "fyne.io/systray"
- "github.com/fyne-io/image/ico"
- "github.com/wailsapp/wails/v2/pkg/runtime"
-)
-
-var supportTrayQuitOnce sync.Once
-
-// startTray restores the background-agent behavior that the legacy Fyne shell
-// exposed. Wails owns the main window; systray only requests Wails actions from
-// its menu callbacks.
-func (s *AppService) startTray() {
- if s.ctx == nil {
- return
- }
- s.trayOnce.Do(func() {
- go systray.Run(func() {
- systray.SetTooltip(s.brand.ProductName)
- if icon := trayIconBytes(s.brand); len(icon) > 0 {
- systray.SetIcon(icon)
- }
-
- show := systray.AddMenuItem(s.brand.ProductName, t("window_title"))
- help := systray.AddMenuItem(t("request_help"), t("request_help"))
- systray.AddSeparator()
- quit := systray.AddMenuItem(t("quit"), t("quit"))
-
- go func() {
- for range show.ClickedCh {
- runtime.WindowUnminimise(s.ctx)
- runtime.WindowShow(s.ctx)
- }
- }()
- go func() {
- for range help.ClickedCh {
- runtime.WindowUnminimise(s.ctx)
- runtime.WindowShow(s.ctx)
- s.emit("open-help", nil)
- }
- }()
- go func() {
- for range quit.ClickedCh {
- s.Quit()
- }
- }()
- }, func() {})
- })
-}
-
-func (s *AppService) stopTray() {
- supportTrayQuitOnce.Do(systray.Quit)
-}
-
-func trayIconBytes(brand Branding) []byte {
- raw := brand.LogoBytes()
- if len(raw) == 0 {
- return nil
- }
- img, _, err := image.Decode(bytes.NewReader(raw))
- if err != nil {
- return nil
- }
- var buf bytes.Buffer
- if err := ico.Encode(&buf, img); err != nil {
- return nil
- }
- return buf.Bytes()
-}
diff --git a/betterdesk-support-agent/ui_helpers.go b/betterdesk-support-agent/ui_helpers.go
deleted file mode 100644
index 9a495ca1..00000000
--- a/betterdesk-support-agent/ui_helpers.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package main
-
-import (
- "strings"
-)
-
-// formatDeviceID inserts spaces every three characters for readability.
-func formatDeviceID(id string) string {
- id = strings.TrimSpace(id)
- if id == "" {
- return "—"
- }
- var b strings.Builder
- for i, r := range id {
- if i > 0 && i%3 == 0 {
- b.WriteRune(' ')
- }
- b.WriteRune(r)
- }
- return b.String()
-}
-
-func maskPassword(pw string) string {
- if pw == "" {
- return "—"
- }
- r := []rune(pw)
- if len(r) <= 2 {
- return strings.Repeat("•", len(r))
- }
- return string(r[:1]) + strings.Repeat("•", len(r)-2) + string(r[len(r)-1:])
-}
-
-func modeFromLabel(label string) string {
- switch label {
- case t("mode_unattended"):
- return AccessUnattended
- case t("mode_disabled"):
- return AccessDisabled
- default:
- return AccessSupervised
- }
-}
diff --git a/betterdesk-support-agent/ui_layout.go b/betterdesk-support-agent/ui_layout.go
deleted file mode 100644
index 2788c405..00000000
--- a/betterdesk-support-agent/ui_layout.go
+++ /dev/null
@@ -1,159 +0,0 @@
-//go:build fyneui
-
-package main
-
-import (
- "fmt"
- "image/color"
- "strings"
- "unicode"
-
- "fyne.io/fyne/v2"
- "fyne.io/fyne/v2/canvas"
- "fyne.io/fyne/v2/container"
- "fyne.io/fyne/v2/theme"
- "fyne.io/fyne/v2/widget"
-)
-
-func (u *ui) brandedTheme() *brandedTheme {
- if th, ok := u.app.Settings().Theme().(*brandedTheme); ok {
- return th
- }
- if th, ok := newBrandedTheme(u.brand).(*brandedTheme); ok {
- return th
- }
- return &brandedTheme{base: theme.DefaultTheme()}
-}
-
-// newInfoBox renders a large branded ID/password box matching the generator preview.
-func (u *ui) newInfoBox(title string, value any, monospace bool, onCopy func()) fyne.CanvasObject {
- th := u.brandedTheme()
- bg := canvas.NewRectangle(th.surface)
- bg.CornerRadius = 10
-
- titleLbl := widget.NewLabelWithStyle(strings.ToUpper(title), fyne.TextAlignLeading, fyne.TextStyle{})
- titleLbl.Importance = widget.LowImportance
-
- var valueWidget fyne.CanvasObject
- if lbl, ok := value.(*widget.Label); ok {
- valueWidget = lbl
- } else if s, ok := value.(string); ok {
- style := fyne.TextStyle{Bold: true}
- if monospace {
- style.Monospace = true
- }
- valueWidget = widget.NewLabelWithStyle(s, fyne.TextAlignLeading, style)
- } else if co, ok := value.(fyne.CanvasObject); ok {
- valueWidget = co
- } else {
- valueWidget = widget.NewLabel(fmt.Sprint(value))
- }
-
- copyBtn := widget.NewButtonWithIcon("", theme.ContentCopyIcon(), func() {
- if onCopy != nil {
- onCopy()
- }
- })
- copyBtn.Importance = widget.LowImportance
-
- valueRow := container.NewBorder(nil, nil, nil, copyBtn, valueWidget)
- inner := container.NewVBox(titleLbl, valueRow)
- return container.NewStack(bg, container.NewPadded(inner))
-}
-
-func (u *ui) buildBrandedHeaderBar() fyne.CanvasObject {
- th := u.brandedTheme()
- bg := canvas.NewRectangle(th.primary)
-
- name := u.brand.CompanyName
- if name == "" {
- name = u.brand.ProductName
- }
- tagline := u.brand.Tagline
- if tagline == "" {
- tagline = u.brand.ProductName
- }
-
- headerText := parseHexColor(u.brand.HeaderTextColor, color.RGBA{R: 255, G: 255, B: 255, A: 255})
- titleCol := canvas.NewText(name, headerText)
- titleCol.TextStyle = fyne.TextStyle{Bold: true}
- titleCol.TextSize = 16
- subCol := canvas.NewText(tagline, headerText)
- subCol.TextStyle = fyne.TextStyle{Italic: true}
- subCol.TextSize = 12
-
- inner := container.NewVBox(titleCol, subCol)
- return container.NewStack(bg, container.NewPadded(inner))
-}
-
-func (u *ui) buildFooterBar() fyne.CanvasObject {
- th := u.brandedTheme()
- bg := canvas.NewRectangle(th.surface)
- bg.SetMinSize(fyne.NewSize(0, 44))
-
- settingsBtn := widget.NewButtonWithIcon("", theme.SettingsIcon(), u.showSettings)
- settingsBtn.Importance = widget.LowImportance
-
- contactLbl := widget.NewLabelWithStyle(u.contactLine(), fyne.TextAlignCenter, fyne.TextStyle{})
- contactLbl.Importance = widget.LowImportance
- contactLbl.Truncation = fyne.TextTruncateClip
-
- quitBtn := widget.NewButtonWithIcon("", theme.CancelIcon(), func() { u.app.Quit() })
- quitBtn.Importance = widget.LowImportance
-
- center := container.NewCenter(contactLbl)
- row := container.NewBorder(nil, nil, settingsBtn, quitBtn, center)
- return container.NewStack(bg, container.NewPadded(row))
-}
-
-func (u *ui) contactLine() string {
- email := strings.TrimSpace(u.brand.SupportEmail)
- if email == "" {
- email = strings.TrimSpace(u.brand.SupportEmailAlt)
- }
- phone := strings.TrimSpace(u.brand.SupportPhone)
- if phone == "" {
- phone = strings.TrimSpace(u.brand.SupportPhoneAlt)
- }
- var parts []string
- if email != "" {
- parts = append(parts, email)
- }
- if phone != "" {
- parts = append(parts, phone)
- }
- if url := strings.TrimSpace(u.brand.ContactURL); url != "" {
- parts = append(parts, url)
- }
- return strings.Join(parts, " • ")
-}
-
-func (u *ui) shouldShowPasswordBox(mode string, custom bool) bool {
- if mode == AccessDisabled {
- return false
- }
- // Supervised and unattended sessions both use the access password —
- // operators need it to connect; the user may still get a consent prompt.
- return true
-}
-
-func newPrimaryButton(label string, icon fyne.Resource, tapped func()) *widget.Button {
- btn := widget.NewButtonWithIcon(label, icon, tapped)
- btn.Importance = widget.HighImportance
- return btn
-}
-
-func newSecondaryButton(label string, icon fyne.Resource, tapped func()) *widget.Button {
- btn := widget.NewButtonWithIcon(label, icon, tapped)
- btn.Importance = widget.MediumImportance
- return btn
-}
-
-func isPrintableASCII(s string) bool {
- for _, r := range s {
- if r > unicode.MaxASCII || !unicode.IsPrint(r) {
- return false
- }
- }
- return true
-}
diff --git a/betterdesk-support-agent/ui_settings.go b/betterdesk-support-agent/ui_settings.go
deleted file mode 100644
index f10aac04..00000000
--- a/betterdesk-support-agent/ui_settings.go
+++ /dev/null
@@ -1,76 +0,0 @@
-//go:build fyneui
-
-package main
-
-import (
- "log"
-
- "fyne.io/fyne/v2"
- "fyne.io/fyne/v2/container"
- "fyne.io/fyne/v2/dialog"
- "fyne.io/fyne/v2/theme"
- "fyne.io/fyne/v2/widget"
-)
-
-func (u *ui) showSettings() {
- deviceID, mode, _, _ := u.state.Snapshot()
-
- modeOptions := u.accessModeOptions()
- modeSelect := widget.NewSelect(modeOptions, nil)
- modeSelect.SetSelected(modeLabel(mode))
-
- langOptions := languageOptions()
- langSelect := widget.NewSelect(langOptions, nil)
- langSelect.SetSelected(languageOptionForCode(u.state.Language))
-
- content := container.NewVBox(
- widget.NewLabelWithStyle(t("settings"), fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
- widget.NewForm(
- widget.NewFormItem(t("access_mode"), modeSelect),
- widget.NewFormItem(t("settings_language"), langSelect),
- ),
- widget.NewButtonWithIcon(t("set_custom"), theme.LoginIcon(), u.showCustomPasswordDialog),
- widget.NewButtonWithIcon(t("regenerate"), theme.ViewRefreshIcon(), func() {
- if err := u.state.RegeneratePassword(); err != nil {
- u.notify(err.Error())
- return
- }
- u.refreshPassword()
- go func() { _ = SyncAccessPassword(u.brand, u.state) }()
- }),
- widget.NewButtonWithIcon(t("test_connection"), theme.SearchIcon(), u.showConnTest),
- widget.NewButton(t("totp_title"), u.showTOTPSettings),
- )
-
- d := dialog.NewCustomConfirm(t("settings"), t("save"), t("cancel"), content, func(ok bool) {
- if !ok {
- return
- }
- if sel := modeSelect.Selected; sel != "" {
- u.onModeChange(sel)
- }
- langChanged := false
- if lang := languageCodeFromOption(langSelect.Selected); lang != "" && lang != u.state.Language {
- if err := u.state.SetLanguage(lang); err != nil {
- u.notify(err.Error())
- return
- }
- setLang(lang)
- langChanged = true
- }
- _, newMode, _, newCustom := u.state.Snapshot()
- if u.pwBox != nil {
- if u.shouldShowPasswordBox(newMode, newCustom) {
- u.pwBox.Show()
- } else {
- u.pwBox.Hide()
- }
- }
- if langChanged {
- u.rebuildMainLayout()
- }
- log.Printf("[support-agent] settings saved device=%s mode=%s", deviceID, newMode)
- }, u.win)
- d.Resize(fyne.NewSize(480, 380))
- d.Show()
-}
diff --git a/betterdesk-support-agent/ui_types.go b/betterdesk-support-agent/ui_types.go
deleted file mode 100644
index d6205e6e..00000000
--- a/betterdesk-support-agent/ui_types.go
+++ /dev/null
@@ -1,7 +0,0 @@
-package main
-
-type consentRequest struct {
- sessionID string
- operator string
- response chan bool
-}
diff --git a/betterdesk-support-agent/urls.go b/betterdesk-support-agent/urls.go
deleted file mode 100644
index 6e464b48..00000000
--- a/betterdesk-support-agent/urls.go
+++ /dev/null
@@ -1,155 +0,0 @@
-package main
-
-import (
- "fmt"
- "net/url"
- "os"
- "strings"
-)
-
-const defaultCDAPPort = 21122
-
-// useTLS reports whether baked branding expects TLS for HTTP/WebSocket calls.
-// Release builds follow the signed profile: HTTPS/WSS when requested, or
-// HTTP/WS for LAN/IP deployments (RustDesk-style). Session encryption remains
-// on the signal/relay protocol layer regardless of transport TLS.
-func (b Branding) useTLS() bool {
- if b.UseHTTPS {
- return true
- }
- addr := strings.TrimSpace(b.ServerAddress)
- if strings.HasPrefix(addr, "https://") || strings.HasPrefix(addr, "wss://") {
- return true
- }
- if b.Server != nil {
- s := strings.TrimSpace(b.Server.Address)
- if strings.HasPrefix(s, "https://") {
- return true
- }
- if strings.HasPrefix(strings.TrimSpace(b.Server.APIURL), "https://") {
- return true
- }
- if strings.HasPrefix(strings.TrimSpace(b.Server.CDAPURL), "wss://") {
- return true
- }
- }
- // Non-release developer builds may force TLS via env; release trusts the
- // signed UseHTTPS / endpoint schemes only (no silent upgrade or downgrade).
- if isReleaseBuild() {
- return false
- }
- return os.Getenv("BETTERDESK_CDAP_TLS") == "1"
-}
-
-func (b Branding) httpScheme() string {
- if b.useTLS() {
- return "https"
- }
- return "http"
-}
-
-func (b Branding) wsScheme() string {
- if b.useTLS() {
- return "wss"
- }
- return "ws"
-}
-
-func (b Branding) cdapPort() int {
- if b.Server != nil && b.Server.CDAPPort > 0 {
- return b.Server.CDAPPort
- }
- return defaultCDAPPort
-}
-
-func hostFromAddr(addr string) string {
- addr = strings.TrimSpace(addr)
- if addr == "" {
- return "localhost"
- }
- withScheme := addr
- if !strings.HasPrefix(addr, "http://") && !strings.HasPrefix(addr, "https://") &&
- !strings.HasPrefix(addr, "ws://") && !strings.HasPrefix(addr, "wss://") {
- withScheme = "http://" + addr
- }
- u, err := url.Parse(withScheme)
- if err == nil && u.Hostname() != "" {
- return u.Hostname()
- }
- host := strings.Split(addr, "/")[0]
- host = strings.Trim(host, "[]")
- if i := strings.LastIndex(host, ":"); i > 0 && !strings.Contains(host, "]") {
- host = host[:i]
- }
- if host == "" {
- return "localhost"
- }
- return host
-}
-
-func schemeFromAddr(addr string) string {
- addr = strings.TrimSpace(addr)
- if strings.HasPrefix(addr, "https://") || strings.HasPrefix(addr, "wss://") {
- return "https"
- }
- return "http"
-}
-
-func formatHostPort(host string, port int, tls bool) string {
- if host == "" {
- host = "localhost"
- }
- scheme := "http"
- if tls {
- scheme = "https"
- }
- if (tls && port == 443) || (!tls && port == 80) {
- return fmt.Sprintf("%s://%s", scheme, host)
- }
- return fmt.Sprintf("%s://%s:%d", scheme, host, port)
-}
-
-// CDAPHealthURL is the gateway health probe target.
-func (b Branding) CDAPHealthURL() string {
- host := hostFromAddr(b.ServerAddress)
- return formatHostPort(host, b.cdapPort(), b.useTLS()) + "/cdap/health"
-}
-
-// APIHealthURL is the Go management API health probe (enrollment, devices, branding).
-func (b Branding) APIHealthURL() string {
- return apiBaseURL(b) + "/health"
-}
-
-// CDAPWebSocketURL is the remote-session gateway URL passed to the engine.
-func (b Branding) CDAPWebSocketURL() string {
- if b.Server != nil && strings.TrimSpace(b.Server.CDAPURL) != "" {
- u := strings.TrimRight(strings.TrimSpace(b.Server.CDAPURL), "/")
- lower := strings.ToLower(u)
- if isReleaseBuild() && !strings.HasPrefix(lower, "wss://") && !strings.HasPrefix(lower, "ws://") {
- return ""
- }
- return u
- }
- host := hostFromAddr(b.ServerAddress)
- if strings.Contains(host, ":") {
- host = "[" + host + "]"
- }
- return fmt.Sprintf("%s://%s:%d/cdap", b.wsScheme(), host, b.cdapPort())
-}
-
-// apiOriginHostPort extracts host:port from the baked API address for logging.
-func (b Branding) apiOriginHostPort() string {
- addr := strings.TrimSpace(b.ServerAddress)
- if addr == "" && b.Server != nil {
- addr = b.Server.Address
- }
- withScheme := addr
- if !strings.HasPrefix(addr, "http://") && !strings.HasPrefix(addr, "https://") {
- withScheme = b.httpScheme() + "://" + addr
- }
- u, err := url.Parse(withScheme)
- if err != nil || u.Host == "" {
- return addr
- }
- return u.Host
-}
diff --git a/betterdesk-support-agent/urls_test.go b/betterdesk-support-agent/urls_test.go
deleted file mode 100644
index 079367d7..00000000
--- a/betterdesk-support-agent/urls_test.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package main
-
-import "testing"
-
-func TestAPIHealthURLHTTP(t *testing.T) {
- b := Branding{
- ServerAddress: "http://78.31.94.73:21114",
- }.normalize()
- got := b.APIHealthURL()
- want := "http://78.31.94.73:21114/api/health"
- if got != want {
- t.Fatalf("got %q want %q", got, want)
- }
-}
-
-func TestCDAPHealthURLHTTP(t *testing.T) {
- b := Branding{
- ServerAddress: "http://78.31.94.73:21114",
- Server: &ServerBranding{
- CDAPPort: 21122,
- },
- }.normalize()
- got := b.CDAPHealthURL()
- want := "http://78.31.94.73:21122/cdap/health"
- if got != want {
- t.Fatalf("got %q want %q", got, want)
- }
-}
-
-func TestCDAPWebSocketURLWithHTTPS(t *testing.T) {
- b := Branding{
- ServerAddress: "https://desk.example.com:21114",
- UseHTTPS: true,
- Server: &ServerBranding{
- CDAPURL: "wss://desk.example.com:21122/cdap",
- },
- }.normalize()
- got := b.CDAPWebSocketURL()
- if got != "wss://desk.example.com:21122/cdap" {
- t.Fatalf("got %q", got)
- }
-}
-
-func TestCDAPWebSocketURLWithHTTP(t *testing.T) {
- b := Branding{
- ServerAddress: "http://192.168.0.110:5443",
- UseHTTPS: false,
- Server: &ServerBranding{
- CDAPURL: "ws://192.168.0.110:21122/cdap",
- },
- }.normalize()
- got := b.CDAPWebSocketURL()
- if got != "ws://192.168.0.110:21122/cdap" {
- t.Fatalf("got %q", got)
- }
- if b.useTLS() {
- t.Fatal("HTTP profile must not force TLS")
- }
-}
-
-func TestHostFromAddr(t *testing.T) {
- if got := hostFromAddr("https://78.31.94.73:21114"); got != "78.31.94.73" {
- t.Fatalf("host: %q", got)
- }
- if got := hostFromAddr("78.31.94.73"); got != "78.31.94.73" {
- t.Fatalf("bare host: %q", got)
- }
-}
diff --git a/betterdesk-support-agent/wails.json b/betterdesk-support-agent/wails.json
deleted file mode 100644
index e099ce6c..00000000
--- a/betterdesk-support-agent/wails.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "$schema": "https://wails.io/schemas/config.v2.json",
- "name": "betterdesk-support",
- "outputfilename": "betterdesk-support",
- "frontend": {
- "dir": "frontend/ui",
- "install": "echo skip",
- "build": "echo skip",
- "dev": false
- },
- "author": {
- "name": "UNITRONIX",
- "email": ""
- },
- "info": {
- "companyName": "UNITRONIX",
- "productName": "BetterDesk Support",
- "productVersion": "0.1.0"
- }
-}
diff --git a/betterdesk-support-agent/windows/.gitignore b/betterdesk-support-agent/windows/.gitignore
deleted file mode 100644
index a1ef6bb9..00000000
--- a/betterdesk-support-agent/windows/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-# Mesa software OpenGL for Windows builds (fetched by scripts/fetch-mesa-windows.sh).
-opengl32.dll
diff --git a/betterdesk.sh b/betterdesk.sh
index 8dfd524b..cbbeccdb 100644
--- a/betterdesk.sh
+++ b/betterdesk.sh
@@ -6049,57 +6049,11 @@ CREDEOF
# Build Functions
#===============================================================================
-# Stage Go support-agent sources where the Node.js build worker expects them.
-# Called after console updates and toolchain install so Generator builds work
-# without a full git checkout on the production host.
+# Legacy Support Agent source staging — Generator now downloads BetterDesk-Client
+# templates into data/modules/betterdesk-support-generator/. Kept as a no-op so
+# older install/update call sites do not fail.
stage_support_agent_source() {
- local repo_root="${1:-$SCRIPT_DIR}"
- local console_path="${CONSOLE_PATH:-/opt/BetterDeskConsole}"
- local base="$console_path/agent-source"
- local build_user="${SUDO_USER:-${BUILD_USER:-unitronix}}"
-
- local support_src="$repo_root/betterdesk-support-agent"
- local agent_lib_src="$repo_root/betterdesk-agent"
- local server_lib_src="$repo_root/betterdesk-server"
- local support_dst="$base/betterdesk-support-agent"
- local agent_lib_dst="$base/betterdesk-agent"
- local server_lib_dst="$base/betterdesk-server"
-
- if [ ! -f "$support_src/build.sh" ]; then
- print_warning "Support agent source not found: $support_src (Generator builds will fail)"
- return 1
- fi
-
- mkdir -p "$base"
- local staged=0
- for pair in "$support_src:$support_dst" "$agent_lib_src:$agent_lib_dst" "$server_lib_src:$server_lib_dst"; do
- local src="${pair%%:*}"
- local dst="${pair#*:}"
- if [ ! -d "$src" ]; then
- print_warning "Missing agent source tree: $src"
- continue
- fi
- if command -v rsync &>/dev/null; then
- rsync -a --delete \
- --exclude '.git/' \
- --exclude 'dist/' \
- --exclude 'data/' \
- "$src/" "$dst/"
- else
- rm -rf "$dst"
- mkdir -p "$dst"
- cp -a "$src/." "$dst/"
- fi
- staged=$((staged + 1))
- done
-
- if [ "$staged" -eq 0 ]; then
- print_error "No support-agent sources staged"
- return 1
- fi
-
- chown -R "$build_user:$build_user" "$base" 2>/dev/null || true
- print_success "Go support-agent source staged at $base"
+ print_info "Support Generator uses Client templates (module install); skipping Go support-agent staging"
return 0
}
diff --git a/docs/wiki/Client-Generator.md b/docs/wiki/Client-Generator.md
index 36c6af32..c979854c 100644
--- a/docs/wiki/Client-Generator.md
+++ b/docs/wiki/Client-Generator.md
@@ -1,75 +1,91 @@
# Client Generator
-The **Support Agent Generator** in the web console builds branded **BetterDesk Support Agent** installers with your server address, public key, and appearance baked in. End users download from a public hub page — no manual network configuration.
+The **BetterDesk Support Generator** in the web console builds **incoming-only** desktop installers from BetterDesk-Client portable templates. Each build injects a server-locked `custom.txt` (rendezvous, relay, API, public key). End users download from a public hub page — no manual network configuration.
-BetterDesk focuses on **Support Agent** as the supported end-user client. Legacy Agent Client / RdClient workers may still exist in the codebase but are not offered in the Generator UI.
+Appearance (logo, colors) is **not** baked into installers. The desktop client loads branding at runtime from the Console **Client Branding API**.
---
## What you get
-Each Support Agent bundle includes:
+Each Support bundle produces portable artifacts that:
-- Server / API / CDAP connection profile
-- Server public key
-- Optional company branding (colors, logo, product name, contact)
-- Optional unattended access flag
-- Incoming capability defaults (desktop, files, clipboard, audio, terminal, restart)
+- Use BetterDesk-Client desktop binaries (AGPL)
+- Force **incoming-only** (`conn-type: incoming`)
+- Override server settings via signed or plain `custom.txt`
+- Target Windows / Linux / macOS (x64 + ARM64 portable)
-### Platforms (Windows + Linux)
+| Platform | Format |
+|----------|--------|
+| Windows x64 / ARM64 | Portable `.zip` |
+| Linux x64 / ARM64 | Portable `.tar.gz` |
+| macOS Intel / Apple Silicon | Portable `.tar.gz` |
-| Platform | Formats |
-|----------|---------|
-| Windows x64 | Portable `.exe`, installed `.msi` |
-| Linux x64 | Portable `.tar.gz`, AppImage, `.deb`, `.rpm` |
+---
-You can deselect platforms when creating or rebuilding a bundle (for example Windows-only) to shorten the first build.
+## Module install (first run)
+
+Before creating bundles, admins install the **betterdesk-support-generator** module:
+
+1. Open **Generator**
+2. Read the AGPL / incoming-only notice and click **Accept terms**
+3. Click **Install from GitHub** — downloads `generator-templates-*.tar.gz` from [BetterDesk-Client](https://github.com/UNITRONIX/BetterDesk-Client) Releases (`BETTERDESK_CLIENT_REPO`, default `UNITRONIX/BetterDesk-Client`)
+4. Click **Finish installation** when status is `ready`
+
+Module data lives under:
+
+```text
+{dataDir}/modules/betterdesk-support-generator/
+ state.json
+ templates/ # extracted generator-templates layout + manifest.json
+ custom-client-signing.seed # optional; from env or file
+```
+
+Optional signing seed:
+
+- Env: `BETTERDESK_CUSTOM_CLIENT_SIGNING_SEED` (base64 32-byte NaCl seed)
+- Or file `custom-client-signing.seed` copied into the module dir
+
+Without a seed, Generator writes **plain JSON** `custom.txt` (Phase A). With a seed matching the client’s embedded `.pub`, it writes **signed** base64 blobs (Phase B).
---
## Quick start
-1. Log in as **admin**
-2. Open **Generator** in the sidebar
-3. Click **New Support Agent**
-4. Enter an internal bundle name and confirm the public server host (prefilled from console defaults)
-5. Optionally expand **Branding & appearance** for logo / colors / contact
-6. Confirm build platforms (all selected by default) and **Save**
-7. Watch build status (Ready / Queued / Building / Failed); use **Retry** on failed platforms
-8. Share the download hub link (`/d/:slug`)
+1. Log in as **admin** and finish module install
+2. Open **Generator** → **New Support**
+3. Enter bundle name, optional app name, confirm server / relay / API (prefilled from console defaults)
+4. Select platforms and **Save**
+5. Watch build status; share the download hub link (`/d/:slug`)
-### After a BetterDesk update
+### Connection fields
-When agent source changes, the panel records a pending Support Agent rebuild
-and completes the console/server update first. After the console restarts, the
-worker synchronizes `agent-source/` and **requeues all non-revoked Support Agent
-bundles** in the background. Check Settings → Updates and the Generator status
-for the deferred sync/build state.
+Defaults come from `/api/generator/defaults` (`keyService` + `clientConfigHost`):
-If a Support Agent signed profile is **incomplete or expired**, Rebuild / Retry / auto-requeue **re-issues** the profile (connection URLs + TTL) before compiling. You can still **Save** the bundle in Generator to refresh the profile manually.
+- Server host / relay host
+- HTTPS toggle + API port
+- Server public key (`id_ed25519.pub`)
-### Toolchain
+The worker writes `custom.txt` beside the binary (or under `Contents/MacOS` on macOS) using the Support Agent example shape (`override-settings`, `conn-type: incoming`).
-Support Agent builds need Go + CGO on the console host:
+---
-- **mingw-w64** for Windows cross-builds (`x86_64-w64-mingw32-gcc`)
-- **wixl** (msitools) for MSI
-- **appimagetool** as an **extracted wrapper** under `/usr/local/lib/appimagetool` (raw AppImage in `/usr/local/bin` fails for the `betterdesk` service user)
-- `dpkg-deb` / `rpmbuild` for Linux packages
-- **WebView2** runtime on end-user Windows machines (Wails UI; usually preinstalled on Windows 10/11)
-- **webkit2gtk** on Linux build/runtime hosts for the Wails UI
+## Architecture notes
-Install via `sudo ./scripts/install-build-toolchain.sh` or `betterdesk.sh` menu **B**, then restart `betterdesk-console`. The Generator banner reports Go, mingw, wixl, and appimagetool.
+| Piece | Role |
+|-------|------|
+| `supportGeneratorModule.js` | Terms + GitHub template install gate |
+| `customTxtBuilder.js` | Build / sign `custom.txt` (`tweetnacl`) |
+| `clientTemplateWorker.js` | Queue builds, inject templates, store artifacts in `data/agent-builds/` |
+| `agent_bundles` / `agent_bundle_builds` | Existing DB tables (product_type `betterdesk-support`) |
-**UI:** Support Agent defaults to **Wails** (HTML UI + Go bindings). Legacy Fyne builds remain available with `BETTERDESK_SUPPORT_FYNEUI=1` (may embed Mesa OpenGL DLLs). Remote desktop capture uses ffmpeg (`ddagrab`/`gdigrab` on Windows) with hardware H.264/VP8/VP9/AV1/H.265 when available.
-
-**Note:** Do not set mingw `CC` before branding seal — `sealbranding` is a host (Linux) Go tool and must run with `CGO_ENABLED=0` / native compilers. Windows `CC`/`CXX` apply only to the final cross-compile.
+Legacy Go **Support Agent** (`betterdesk-support-agent`) and compile-on-console workers are removed. Old product types (`support-agent`, `agent`, `agent-client`, `rdclient`) normalize to `betterdesk-support` for compatibility.
---
## Security notes
- Bundles do **not** embed a shared enrollment token
-- Each install registers independently; in **managed** mode a unique `device_token` is issued only after operator approval
-- Release builds seal branding inside the binary (obfuscation + integrity); local state is machine-bound AES-GCM
-- Support Agent is **inbound-only** — end users cannot browse or connect to other devices on your infrastructure
+- Each install registers independently; managed mode issues a `device_token` after operator approval
+- Support clients are **inbound-only** — end users cannot browse or connect outbound to other devices on your infrastructure
+- Prefer signed `custom.txt` in production (seed on console must match the pubkey baked into Client releases)
diff --git a/scripts/check-no-sensitive-paths.sh b/scripts/check-no-sensitive-paths.sh
index 95497eff..218baacf 100644
--- a/scripts/check-no-sensitive-paths.sh
+++ b/scripts/check-no-sensitive-paths.sh
@@ -16,7 +16,6 @@ SCAN_PATHS=(
betterdesk-agent/
betterdesk-agent-client/
betterdesk-server/
- betterdesk-support-agent/
web-nodejs/
sdks/
scripts/
diff --git a/web-nodejs/lib/generatorBuildTypes.js b/web-nodejs/lib/generatorBuildTypes.js
index 115747e4..fb6d4d2e 100644
--- a/web-nodejs/lib/generatorBuildTypes.js
+++ b/web-nodejs/lib/generatorBuildTypes.js
@@ -2,33 +2,37 @@
/**
* Canonical product and queue values shared by generator persistence, routes,
- * and build workers. Legacy rows used "agent" for Support Agent bundles.
+ * and build workers. Legacy rows used "agent" / "support-agent" for the old
+ * Go Support Agent; they normalize to betterdesk-support (template + custom.txt).
*/
const PRODUCT_TYPES = Object.freeze({
- SUPPORT_AGENT: 'support-agent',
- AGENT_CLIENT: 'agent-client',
- RDCLIENT: 'rdclient',
+ BETTERDESK_SUPPORT: 'betterdesk-support',
});
const QUEUED_BUILD_STATUSES = new Set(['queued', 'pending']);
function canonicalProductType(raw) {
const value = String(raw ?? '').trim().toLowerCase();
- if (value === PRODUCT_TYPES.RDCLIENT) return PRODUCT_TYPES.RDCLIENT;
- if (value === PRODUCT_TYPES.AGENT_CLIENT || value === 'agent_client') {
- return PRODUCT_TYPES.AGENT_CLIENT;
- }
- if (value === PRODUCT_TYPES.SUPPORT_AGENT || value === 'support_agent' || value === 'agent') {
- return PRODUCT_TYPES.SUPPORT_AGENT;
+ if (
+ value === PRODUCT_TYPES.BETTERDESK_SUPPORT
+ || value === 'betterdesk_support'
+ || value === 'support-agent'
+ || value === 'support_agent'
+ || value === 'agent'
+ || value === 'agent-client'
+ || value === 'agent_client'
+ || value === 'rdclient'
+ ) {
+ return PRODUCT_TYPES.BETTERDESK_SUPPORT;
}
return null;
}
-function normalizeProductType(raw, fallback = PRODUCT_TYPES.SUPPORT_AGENT) {
+function normalizeProductType(raw, fallback = PRODUCT_TYPES.BETTERDESK_SUPPORT) {
return canonicalProductType(raw)
|| canonicalProductType(fallback)
- || PRODUCT_TYPES.SUPPORT_AGENT;
+ || PRODUCT_TYPES.BETTERDESK_SUPPORT;
}
function isProductType(raw, expected) {
diff --git a/web-nodejs/public/css/generator.css b/web-nodejs/public/css/generator.css
index d879ccac..8f99a95d 100644
--- a/web-nodejs/public/css/generator.css
+++ b/web-nodejs/public/css/generator.css
@@ -1,7 +1,40 @@
/* =========================================================================
- Agent Generator panel
+ BetterDesk Support Generator panel
========================================================================= */
+.generator-module-gate {
+ margin-bottom: 20px;
+}
+
+.gen-module-intro {
+ margin: 0 0 12px;
+ line-height: 1.5;
+}
+
+.gen-module-bullets {
+ margin: 0 0 16px;
+ padding-left: 1.25rem;
+ line-height: 1.5;
+}
+
+.gen-module-status {
+ margin-bottom: 16px;
+ padding: 10px 12px;
+ border-radius: 8px;
+ background: var(--color-bg-elevated, rgba(255, 255, 255, 0.03));
+ font-size: 13px;
+}
+
+.gen-module-status.is-error {
+ color: var(--color-danger, #ef4444);
+}
+
+.gen-module-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
.generator-layout {
display: grid;
grid-template-columns: 320px 1fr;
@@ -45,586 +78,132 @@
margin-top: 2px;
}
-.gen-advanced {
- margin: 12px 0 20px;
- border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
- border-radius: 10px;
- padding: 0 14px 14px;
- background: var(--color-bg-elevated, rgba(255, 255, 255, 0.02));
-}
-
-.gen-advanced-summary {
- display: flex;
- align-items: center;
- gap: 8px;
- cursor: pointer;
- font-weight: 600;
- padding: 12px 0;
- list-style: none;
-}
-
-.gen-advanced-summary::-webkit-details-marker {
- display: none;
-}
-
-.gen-advanced-summary .material-icons {
- font-size: 18px;
- opacity: 0.8;
-}
-
/* ---- Bundle list ---- */
.generator-bundles .card-header {
display: flex;
justify-content: space-between;
align-items: center;
- gap: 12px;
+ gap: 8px;
}
.bundle-list {
display: flex;
flex-direction: column;
- gap: 8px;
- max-height: 70vh;
- overflow-y: auto;
+ gap: 6px;
}
.bundle-item {
display: flex;
flex-direction: column;
- gap: 4px;
- padding: 12px 14px;
- border-radius: 10px;
+ align-items: flex-start;
+ gap: 2px;
+ width: 100%;
+ text-align: left;
+ padding: 10px 12px;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
- background: var(--color-bg-elevated, rgba(255, 255, 255, 0.03));
+ border-radius: 8px;
+ background: transparent;
+ color: inherit;
cursor: pointer;
- transition: background 0.15s ease, border-color 0.15s ease;
-}
-
-.bundle-item:hover {
- background: var(--color-bg-hover, rgba(255, 255, 255, 0.06));
}
+.bundle-item:hover,
.bundle-item.active {
border-color: var(--color-primary, #2563eb);
- background: rgba(37, 99, 235, 0.08);
+ background: var(--color-bg-elevated, rgba(37, 99, 235, 0.08));
}
-.bundle-item-title {
+.bundle-item.revoked {
+ opacity: 0.6;
+}
+
+.bundle-item-name {
font-weight: 600;
- display: flex;
- align-items: center;
- gap: 6px;
}
.bundle-item-meta {
font-size: 12px;
- color: var(--color-text-muted, #8b949e);
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.bundle-item .badge-revoked {
- display: inline-flex;
- align-items: center;
- font-size: 10px;
- padding: 2px 6px;
- border-radius: 4px;
- background: rgba(220, 53, 69, 0.15);
- color: #f87171;
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-/* ---- Editor ---- */
-
-.generator-editor .card-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 12px;
- flex-wrap: wrap;
+ opacity: 0.7;
}
.editor-actions {
display: flex;
- gap: 8px;
-}
-
-.editor-grid {
- display: grid;
- grid-template-columns: minmax(0, 1fr) 320px;
- gap: 24px;
-}
-
-@media (max-width: 1280px) {
- .editor-grid {
- grid-template-columns: 1fr;
- }
-}
-
-.editor-fields .section-title {
- margin-top: 20px;
- margin-bottom: 12px;
- padding-top: 16px;
- border-top: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
- font-size: 14px;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- color: var(--color-text-muted, #8b949e);
-}
-
-.editor-fields .section-title:first-child {
- margin-top: 0;
- padding-top: 0;
- border-top: none;
-}
-
-.form-row {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 12px;
-}
-
-@media (max-width: 600px) {
- .form-row { grid-template-columns: 1fr; }
-}
-
-.form-group-checkbox {
- display: flex;
- flex-direction: column;
- justify-content: flex-end;
-}
-
-.form-color {
- height: 40px;
- padding: 4px;
- cursor: pointer;
-}
-
-.empty-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 60px 20px;
- color: var(--color-text-muted, #8b949e);
- text-align: center;
-}
-
-.empty-state .material-icons {
- font-size: 56px;
- margin-bottom: 12px;
- opacity: 0.6;
-}
-
-/* ---- Live preview ---- */
-
-.editor-preview {
- position: sticky;
- top: 20px;
-}
-
-.agent-preview {
- margin-top: 8px;
-}
-
-.agent-preview-frame {
- width: 100%;
- border-radius: 14px;
- overflow: hidden;
- border: 1px solid rgba(15, 23, 42, 0.12);
- background: var(--brand-bg, #ffffff);
- color: var(--brand-text, #1f2937);
- font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
- box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
-}
-
-.ap-topbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- padding: 12px 14px 6px;
-}
-
-.ap-topbar-brand {
- display: flex;
- align-items: center;
- gap: 10px;
- min-width: 0;
-}
-
-.ap-logo {
- width: 28px;
- height: 28px;
- border-radius: 6px;
- background: color-mix(in srgb, var(--brand-primary, #2563eb) 12%, transparent);
- color: var(--brand-primary, #2563eb);
- display: flex;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- flex-shrink: 0;
-}
-
-.ap-logo img {
- width: 100%;
- height: 100%;
- object-fit: contain;
-}
-
-.ap-logo .material-icons { font-size: 18px; }
-
-.ap-brand { min-width: 0; }
-
-.ap-brand-name {
- font-weight: 700;
- font-size: 14px;
- color: var(--brand-header-text, #1f2937);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.ap-brand-text {
- font-size: 11px;
- color: var(--brand-text-muted, #6b7280);
- margin-top: 1px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.ap-body {
- padding: 6px 14px 14px;
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.ap-hero {
- min-height: 88px;
- border-radius: 12px;
- background: var(--brand-accent, #e0f2fe);
- display: flex;
- align-items: center;
- justify-content: center;
- overflow: hidden;
-}
-
-.ap-hero img {
- max-height: 72px;
- max-width: 70%;
- object-fit: contain;
-}
-
-.ap-hero .material-icons {
- font-size: 42px;
- color: var(--brand-primary, #2563eb);
- opacity: 0.85;
-}
-
-.ap-support {
- text-align: center;
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.ap-section-title {
- font-size: 17px;
- font-weight: 700;
- letter-spacing: -0.02em;
-}
-
-.ap-section-hint {
- margin: 0;
- font-size: 12px;
- line-height: 1.4;
- color: var(--brand-text-muted, #6b7280);
-}
-
-.ap-cta-row {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 8px;
-}
-
-.ap-divider {
- display: flex;
- align-items: center;
- gap: 10px;
- color: var(--brand-text-muted, #6b7280);
- font-size: 11px;
-}
-
-.ap-divider::before,
-.ap-divider::after {
- content: "";
- flex: 1;
- height: 1px;
- background: color-mix(in srgb, var(--brand-text, #1f2937) 12%, transparent);
-}
-
-.ap-cred-card {
- display: grid;
- grid-template-columns: 1fr 1fr auto;
- gap: 10px;
- align-items: center;
- background: var(--brand-surface, #f3f4f6);
- border-radius: 10px;
- padding: 12px;
-}
-
-.ap-cred-col {
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.ap-id-label {
- font-size: 10px;
- color: var(--brand-text-muted, #6b7280);
-}
-
-.ap-id-value {
- font-family: ui-monospace, SFMono-Regular, monospace;
- font-size: 14px;
- letter-spacing: 0.5px;
- font-weight: 700;
- word-break: break-all;
-}
-
-.ap-pw { color: var(--brand-text, #1f2937); }
-
-.ap-cred-actions {
- display: flex;
- flex-direction: column;
- gap: 4px;
- color: var(--brand-text-muted, #6b7280);
-}
-
-.ap-cred-actions .material-icons { font-size: 16px; }
-
-.ap-help,
-.ap-chat {
- margin: 0;
- padding: 9px 10px;
- border-radius: 999px;
- font-size: 12px;
- font-weight: 600;
- cursor: not-allowed;
- opacity: 0.9;
-}
-
-.ap-help {
- background: transparent;
- color: var(--brand-primary, #2563eb);
- border: 1px solid var(--brand-primary, #2563eb);
-}
-
-.ap-chat {
- background: var(--brand-primary, #2563eb);
- color: #fff;
- border: 1px solid var(--brand-primary, #2563eb);
-}
-
-.ap-footer {
- padding: 10px 14px 12px;
- display: flex;
- align-items: center;
- gap: 8px;
- border-top: 1px solid color-mix(in srgb, var(--brand-text, #1f2937) 10%, transparent);
-}
-
-.ap-status-dot {
- width: 9px;
- height: 9px;
- border-radius: 50%;
- background: var(--brand-status-ready, #22c55e);
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand-status-ready, #22c55e) 22%, transparent);
- flex-shrink: 0;
-}
-
-.ap-status-text {
- flex: 1;
- min-width: 0;
- font-size: 11px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.ap-icon-btn {
- width: 28px;
- height: 28px;
- border: none;
- background: transparent;
- color: var(--brand-text-muted, #6b7280);
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 6px;
- cursor: not-allowed;
-}
-
-.ap-icon-btn .material-icons { font-size: 18px; }
-
-.ap-contact {
- max-width: 34%;
- font-size: 10px;
- color: var(--brand-text-muted, #6b7280);
- text-align: right;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.color-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 12px;
-}
-
-/* ---- Download link block ---- */
-
-.download-info {
- margin-top: 24px;
-}
-
-.builds-section {
- margin-bottom: 24px;
- padding-bottom: 20px;
- border-bottom: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
-}
-
-.builds-section-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
flex-wrap: wrap;
+ gap: 8px;
}
-.builds-section-header .section-title {
- margin: 0;
-}
-
-.builds-summary {
- font-size: 13px;
- color: var(--color-text-muted, #8b949e);
- margin: 8px 0 12px;
-}
-
-.builds-table {
- width: 100%;
- border-collapse: collapse;
- font-size: 13px;
-}
-
-.builds-table th,
-.builds-table td {
- padding: 8px 10px;
- text-align: left;
- border-bottom: 1px solid var(--color-border, rgba(255, 255, 255, 0.06));
- vertical-align: top;
-}
-
-.builds-table th {
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 0.4px;
- color: var(--color-text-muted, #8b949e);
-}
-
-.build-error {
- margin-top: 4px;
- font-size: 11px;
- color: #f87171;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 420px;
-}
-
-.build-error-hint {
- margin-top: 4px;
- font-size: 11px;
- color: #fbbf24;
-}
-
-.build-actions {
- white-space: nowrap;
- text-align: right;
-}
-
-.toolchain-banner {
- margin: 8px 0 12px;
- padding: 8px 12px;
- border-radius: 6px;
- font-size: 12px;
- line-height: 1.4;
-}
-
-.toolchain-banner--ok {
- background: rgba(34, 197, 94, 0.12);
- color: #4ade80;
- border: 1px solid rgba(34, 197, 94, 0.25);
-}
-
-.toolchain-banner--warn {
- background: rgba(245, 158, 11, 0.12);
- color: #fbbf24;
- border: 1px solid rgba(245, 158, 11, 0.3);
-}
-
-.build-badge {
- display: inline-flex;
- align-items: center;
- padding: 2px 8px;
- border-radius: 999px;
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.3px;
-}
-
-.build-badge--ready { background: rgba(34, 197, 94, 0.15); color: #4ade80; }
-.build-badge--pending { background: rgba(148, 163, 184, 0.15); color: #94a3b8; }
-.build-badge--building { background: rgba(37, 99, 235, 0.15); color: #60a5fa; }
-.build-badge--failed { background: rgba(220, 53, 69, 0.15); color: #f87171; }
-
.download-link-row {
display: flex;
- gap: 8px;
align-items: center;
- flex-wrap: wrap;
+ gap: 8px;
}
-.download-link-row .form-input { flex: 1; min-width: 200px; }
-
.download-link-row .input-prefix {
- color: var(--text-muted, #94a3b8);
- font-family: var(--font-mono, monospace);
- white-space: nowrap;
- user-select: none;
+ opacity: 0.7;
+ font-family: monospace;
}
-/* ---- Validation errors ---- */
+.download-link-row .form-input {
+ flex: 1;
+}
+
+.builds-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+}
+
+.builds-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.build-row {
+ padding: 10px 12px;
+ border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
+ border-radius: 8px;
+}
+
+.build-row-main {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+
+.build-error {
+ margin-top: 6px;
+ font-size: 12px;
+ color: var(--color-danger, #ef4444);
+}
.validation-errors {
- margin-top: 16px;
- padding: 12px 14px;
- background: rgba(220, 53, 69, 0.1);
- border: 1px solid rgba(220, 53, 69, 0.3);
- border-radius: 8px;
- color: #f87171;
- font-size: 13px;
+ margin-top: 12px;
+ color: var(--color-danger, #ef4444);
}
-.validation-errors ul { margin: 4px 0 0 18px; }
+.section-title {
+ margin: 18px 0 8px;
+ font-size: 14px;
+ font-weight: 600;
+}
-.hidden { display: none !important; }
+.empty-state {
+ text-align: center;
+ padding: 40px 16px;
+ opacity: 0.7;
+}
+
+.empty-state .material-icons {
+ font-size: 40px;
+ display: block;
+ margin-bottom: 8px;
+}
+
+.hidden {
+ display: none !important;
+}
diff --git a/web-nodejs/public/js/generator.js b/web-nodejs/public/js/generator.js
index 16b3399e..c29994de 100644
--- a/web-nodejs/public/js/generator.js
+++ b/web-nodejs/public/js/generator.js
@@ -1,6 +1,6 @@
/* =========================================================================
- BetterDesk Support Agent Generator
- Create branded Support Agent installers; builds run on this console host.
+ BetterDesk Support Generator
+ Module install gate + simple custom.txt template bundles.
========================================================================= */
(function () {
@@ -14,7 +14,7 @@
const csrf = () => (window.BetterDesk && window.BetterDesk.csrfToken) || '';
async function api(method, url, body) {
- const headers = { 'Accept': 'application/json' };
+ const headers = { Accept: 'application/json' };
const writeMethods = method === 'POST' || method === 'PUT' || method === 'PATCH';
if (writeMethods) headers['Content-Type'] = 'application/json';
if (method !== 'GET' && method !== 'HEAD') headers['X-CSRF-Token'] = csrf();
@@ -41,67 +41,50 @@
currentId: null,
currentBundle: null,
currentBuilds: [],
- platformLabels: {},
platforms: [],
selectedPlatforms: new Set(),
dirty: false,
slugManual: false,
- previewTimer: null,
buildsPollTimer: null,
- productType: 'support-agent',
+ moduleReady: false,
+ moduleStatus: null,
+ productType: 'betterdesk-support',
};
const $ = (id) => document.getElementById(id);
const els = {};
function cacheEls() {
- ['gen-new-support', 'gen-bundle-list', 'gen-editor-title', 'gen-revoke-btn', 'gen-delete-btn', 'gen-save-btn',
- 'gen-rebuild-btn', 'gen-builds-list', 'gen-builds-summary', 'gen-toolchain-banner', 'gen-platforms',
- 'gen-empty-state', 'gen-editor-form',
- 'gen-name', 'gen-slug', 'gen-company', 'gen-short-text', 'gen-product-label', 'gen-hide-product-type',
- 'gen-email', 'gen-phone', 'gen-url',
- 'gen-server-host', 'gen-use-https', 'gen-token-mask',
- 'gen-logo', 'gen-logo-clear', 'gen-primary', 'gen-accent', 'gen-bg', 'gen-surface', 'gen-text', 'gen-text-muted', 'gen-status-ready', 'gen-header-text', 'gen-lang', 'gen-unattended',
- 'gen-download-info', 'gen-download-url', 'gen-copy-link', 'gen-open-link',
- 'gen-preview', 'gen-prev-logo', 'gen-prev-body-logo', 'gen-prev-name', 'gen-prev-text', 'gen-prev-pw-row', 'gen-prev-contact',
- 'gen-validation-errors', 'gen-advanced-branding'
- ].forEach(id => { els[id] = $(id); });
+ [
+ 'gen-module-gate', 'gen-module-status', 'gen-accept-terms', 'gen-install-module',
+ 'gen-finish-install', 'gen-main',
+ 'gen-new-support', 'gen-bundle-list', 'gen-editor-title', 'gen-revoke-btn', 'gen-delete-btn',
+ 'gen-save-btn', 'gen-rebuild-btn', 'gen-builds-list', 'gen-builds-summary', 'gen-platforms',
+ 'gen-empty-state', 'gen-editor-form',
+ 'gen-name', 'gen-slug', 'gen-app-name',
+ 'gen-server-host', 'gen-relay-host', 'gen-use-https', 'gen-api-port', 'gen-public-key',
+ 'gen-download-info', 'gen-download-url', 'gen-copy-link', 'gen-open-link',
+ 'gen-validation-errors',
+ ].forEach((id) => { els[id] = $(id); });
}
- const DEFAULT_BRANDING = {
- company_name: '',
- short_text: '',
- product_label: '',
- hide_product_type: false,
- contact_email: '',
- contact_phone: '',
- contact_url: '',
- logo_data_url: '',
- primary_color: '#2563eb',
- accent_color: '#e0f2fe',
- background_color: '#ffffff',
- surface_color: '#f3f4f6',
- text_color: '#1f2937',
- text_muted_color: '#6b7280',
- status_ready_color: '#22c55e',
- header_text_color: '#1f2937',
- allow_unattended: false,
- default_lang: 'en',
+ let connectionDefaults = {
server_host: '',
+ relay_host: '',
use_https: true,
+ api_port: '21114',
+ public_key: '',
+ app_name: 'BetterDesk Support Agent',
};
- let logoDataUrl = '';
- let connectionDefaults = { server_host: '', use_https: true };
-
const SLUG_TRANSLIT = {
- 'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', 'ź': 'z', 'ż': 'z',
- 'ä': 'a', 'ö': 'o', 'ü': 'u', 'ß': 'ss', 'æ': 'ae', 'ø': 'o', 'å': 'a',
- 'č': 'c', 'ď': 'd', 'ě': 'e', 'ň': 'n', 'ř': 'r', 'š': 's', 'ť': 't', 'ů': 'u', 'ý': 'y', 'ž': 'z',
+ ą: 'a', ć: 'c', ę: 'e', ł: 'l', ń: 'n', ó: 'o', ś: 's', ź: 'z', ż: 'z',
+ ä: 'a', ö: 'o', ü: 'u', ß: 'ss', æ: 'ae', ø: 'o', å: 'a',
+ č: 'c', ď: 'd', ě: 'e', ň: 'n', ř: 'r', š: 's', ť: 't', ů: 'u', ý: 'y', ž: 'z',
};
function slugifyName(name) {
- let slug = String(name || '').trim().toLowerCase().split('').map(ch => SLUG_TRANSLIT[ch] ?? ch).join('');
+ let slug = String(name || '').trim().toLowerCase().split('').map((ch) => SLUG_TRANSLIT[ch] ?? ch).join('');
slug = slug.replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
if (slug.length > 32) slug = slug.slice(0, 32).replace(/-$/, '');
return slug;
@@ -142,56 +125,81 @@
function readBranding() {
return {
- company_name: els['gen-company'].value.trim(),
- short_text: els['gen-short-text'].value.trim(),
- product_label: els['gen-product-label'] ? els['gen-product-label'].value.trim() : '',
- hide_product_type: !!(els['gen-hide-product-type'] && els['gen-hide-product-type'].checked),
- contact_email: els['gen-email'].value.trim(),
- contact_phone: els['gen-phone'].value.trim(),
- contact_url: els['gen-url'].value.trim(),
- logo_data_url: logoDataUrl,
- primary_color: els['gen-primary'].value,
- accent_color: els['gen-accent'].value,
- background_color: els['gen-bg'].value,
- surface_color: els['gen-surface'].value,
- text_color: els['gen-text'].value,
- text_muted_color: els['gen-text-muted'].value,
- status_ready_color: els['gen-status-ready'].value,
- header_text_color: els['gen-header-text'].value,
- allow_unattended: !!els['gen-unattended'].checked,
- default_lang: els['gen-lang'].value,
+ app_name: els['gen-app-name'].value.trim(),
+ company_name: els['gen-app-name'].value.trim(),
server_host: els['gen-server-host'].value.trim(),
- use_https: !!els['gen-use-https'].checked,
+ relay_host: els['gen-relay-host'].value.trim(),
+ use_https: !!(els['gen-use-https'] && els['gen-use-https'].checked),
+ api_port: els['gen-api-port'].value.trim(),
+ public_key: els['gen-public-key'].value.trim(),
+ disable_settings: true,
};
}
function writeBranding(b) {
- b = Object.assign({}, DEFAULT_BRANDING, connectionDefaults, b || {});
- els['gen-server-host'].value = b.server_host || b.server?.address?.replace(/^https?:\/\//, '').split(':')[0] || '';
- els['gen-use-https'].checked = b.use_https ?? (b.server?.address?.startsWith('https://') ?? true);
- if (els['gen-token-mask']) {
- els['gen-token-mask'].value = t('generator.enrollment_per_device', 'Per device — approve in Registrations');
+ b = Object.assign({}, connectionDefaults, b || {});
+ els['gen-app-name'].value = b.app_name || b.company_name || connectionDefaults.app_name || '';
+ els['gen-server-host'].value = b.server_host
+ || (b.server?.address ? String(b.server.address).replace(/^https?:\/\//, '').split(':')[0] : '')
+ || '';
+ els['gen-relay-host'].value = b.relay_host || '';
+ els['gen-use-https'].checked = b.use_https ?? connectionDefaults.use_https ?? true;
+ els['gen-api-port'].value = b.api_port || connectionDefaults.api_port || '';
+ els['gen-public-key'].value = b.public_key || b.server_key || b.server?.public_key || connectionDefaults.public_key || '';
+ }
+
+ function platformKey(p) {
+ return `${p.platform}/${p.arch}/${p.format}`;
+ }
+
+ function renderPlatforms() {
+ if (!els['gen-platforms']) return;
+ els['gen-platforms'].innerHTML = state.platforms.map((p) => {
+ const key = platformKey(p);
+ const checked = state.selectedPlatforms.has(key) ? 'checked' : '';
+ return `
+
+ ${p.label || key}
+ `;
+ }).join('');
+ els['gen-platforms'].querySelectorAll('input[type=checkbox]').forEach((input) => {
+ input.addEventListener('change', () => {
+ const key = `${input.dataset.platform}/${input.dataset.arch}/${input.dataset.format}`;
+ if (input.checked) state.selectedPlatforms.add(key);
+ else state.selectedPlatforms.delete(key);
+ markDirty();
+ });
+ });
+ }
+
+ function selectedPlatformPayload() {
+ return state.platforms.filter((p) => state.selectedPlatforms.has(platformKey(p)));
+ }
+
+ function selectAllPlatforms() {
+ state.selectedPlatforms = new Set(state.platforms.map(platformKey));
+ renderPlatforms();
+ }
+
+ function markDirty() {
+ state.dirty = true;
+ if (els['gen-save-btn']) els['gen-save-btn'].disabled = false;
+ }
+
+ function clearDirty() {
+ state.dirty = false;
+ if (els['gen-save-btn']) els['gen-save-btn'].disabled = true;
+ }
+
+ function showValidation(errors) {
+ if (!els['gen-validation-errors']) return;
+ if (!errors || !errors.length) {
+ els['gen-validation-errors'].classList.add('hidden');
+ els['gen-validation-errors'].innerHTML = '';
+ return;
}
- els['gen-company'].value = b.company_name || '';
- els['gen-short-text'].value = b.short_text || '';
- if (els['gen-product-label']) els['gen-product-label'].value = b.product_label || '';
- if (els['gen-hide-product-type']) els['gen-hide-product-type'].checked = !!b.hide_product_type;
- els['gen-email'].value = b.contact_email || '';
- els['gen-phone'].value = b.contact_phone || '';
- els['gen-url'].value = b.contact_url || '';
- els['gen-primary'].value = b.primary_color || '#2563eb';
- els['gen-accent'].value = b.accent_color || '#e0f2fe';
- els['gen-bg'].value = b.background_color || '#ffffff';
- els['gen-surface'].value = b.surface_color || '#f3f4f6';
- els['gen-text'].value = b.text_color || '#1f2937';
- els['gen-text-muted'].value = b.text_muted_color || '#6b7280';
- els['gen-status-ready'].value = b.status_ready_color || '#22c55e';
- els['gen-header-text'].value = b.header_text_color || '#1f2937';
- els['gen-unattended'].checked = !!b.allow_unattended;
- els['gen-lang'].value = b.default_lang || 'en';
- logoDataUrl = b.logo_data_url || '';
- els['gen-logo'].value = '';
- els['gen-logo-clear'].classList.toggle('hidden', !logoDataUrl);
+ els['gen-validation-errors'].classList.remove('hidden');
+ els['gen-validation-errors'].innerHTML = `${errors.map((e) => `${escapeText(e)} `).join('')} `;
}
function escapeText(s) {
@@ -200,91 +208,200 @@
return d.innerHTML;
}
- function renderPreview() {
- const b = readBranding();
- const frame = els['gen-preview'].querySelector('.agent-preview-frame');
- if (frame) {
- frame.style.setProperty('--brand-primary', b.primary_color);
- frame.style.setProperty('--brand-accent', b.accent_color);
- frame.style.setProperty('--brand-bg', b.background_color || '#ffffff');
- frame.style.setProperty('--brand-surface', b.surface_color || '#f3f4f6');
- frame.style.setProperty('--brand-text', b.text_color || '#1f2937');
- frame.style.setProperty('--brand-text-muted', b.text_muted_color || '#6b7280');
- frame.style.setProperty('--brand-status-ready', b.status_ready_color || '#22c55e');
- frame.style.setProperty('--brand-header-text', b.header_text_color || '#1f2937');
+ function renderBundleList() {
+ if (!els['gen-bundle-list']) return;
+ if (!state.bundles.length) {
+ els['gen-bundle-list'].innerHTML = `${t('generator.no_bundles', 'No bundles yet')}
`;
+ return;
}
- const logoHtml = b.logo_data_url
- ? ` `
- : 'support_agent ';
- const logoTop = els['gen-prev-logo'];
- if (logoTop) logoTop.innerHTML = logoHtml;
- const logoHero = els['gen-prev-body-logo'];
- if (logoHero) {
- logoHero.innerHTML = b.logo_data_url
- ? ` `
- : 'devices ';
+ els['gen-bundle-list'].innerHTML = state.bundles.map((b) => {
+ const active = state.currentId === b.bundle_id ? 'active' : '';
+ const revoked = b.revoked ? 'revoked' : '';
+ return `
+ ${escapeText(b.name)}
+ ${escapeText(b.slug || b.public_id || '')}
+ `;
+ }).join('');
+ els['gen-bundle-list'].querySelectorAll('.bundle-item').forEach((btn) => {
+ btn.addEventListener('click', () => openBundle(btn.dataset.id));
+ });
+ }
+
+ function buildStatusLabel(status) {
+ const s = String(status || 'pending').toLowerCase();
+ if (s === 'ready') return t('generator.build_ready', 'Ready');
+ if (s === 'building') return t('generator.build_building', 'Building');
+ if (s === 'queued' || s === 'pending') return t('generator.build_queued', 'Queued');
+ if (s === 'failed') return t('generator.build_failed', 'Failed');
+ return s;
+ }
+
+ function renderBuilds() {
+ if (!els['gen-builds-list']) return;
+ const builds = state.currentBuilds || [];
+ if (!builds.length) {
+ els['gen-builds-list'].innerHTML = `${t('generator.builds_empty', 'No builds yet — save to queue platforms.')}
`;
+ if (els['gen-builds-summary']) els['gen-builds-summary'].textContent = '';
+ return;
}
- els['gen-prev-name'].textContent = b.company_name || t('generator.preview_default_name', 'BetterDesk Support');
- els['gen-prev-text'].textContent = b.short_text || '';
- if (els['gen-prev-pw-row']) els['gen-prev-pw-row'].classList.remove('hidden');
- const parts = [];
- if (b.contact_email) parts.push(b.contact_email);
- if (b.contact_phone) parts.push(b.contact_phone);
- if (b.contact_url) parts.push(b.contact_url);
- els['gen-prev-contact'].textContent = parts.join(' • ');
+ const ready = builds.filter((b) => b.status === 'ready').length;
+ if (els['gen-builds-summary']) {
+ els['gen-builds-summary'].textContent = `${ready}/${builds.length} ready`;
+ }
+ els['gen-builds-list'].innerHTML = builds.map((b) => {
+ const key = `${b.platform}/${b.arch}/${b.format}`;
+ const label = (state.platforms.find((p) => platformKey(p) === key) || {}).label || key;
+ const err = b.error_message ? `${escapeText(b.error_message)}
` : '';
+ const retry = b.status === 'failed'
+ ? `Retry `
+ : '';
+ return `
+
+ ${escapeText(label)}
+ ${escapeText(buildStatusLabel(b.status))}
+ ${retry}
+
+ ${err}
+
`;
+ }).join('');
+ els['gen-builds-list'].querySelectorAll('.gen-retry-build').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ if (!state.currentId || state.currentId === 'new') return;
+ try {
+ const { platform, arch, format } = btn.dataset;
+ await api('POST', `/api/generator/bundles/${state.currentId}/rebuild/${platform}/${arch}/${format}`);
+ notify.success(t('generator.rebuild_queued', 'Rebuild queued'));
+ await refreshBuilds();
+ } catch (err) {
+ notify.error(err.message);
+ }
+ });
+ });
}
- function schedulePreview() {
- if (state.previewTimer) clearTimeout(state.previewTimer);
- state.previewTimer = setTimeout(renderPreview, 60);
+ function setEditorVisible(show) {
+ els['gen-empty-state'].classList.toggle('hidden', show);
+ els['gen-editor-form'].classList.toggle('hidden', !show);
}
- function markDirty() {
- state.dirty = true;
- els['gen-save-btn'].disabled = false;
- schedulePreview();
+ function setEditorForNew() {
+ stopBuildsPoll();
+ state.currentId = 'new';
+ state.currentBundle = null;
+ state.currentBuilds = [];
+ state.slugManual = false;
+ state.productType = 'betterdesk-support';
+ setEditorVisible(true);
+ els['gen-editor-title'].textContent = t('generator.new_bundle', 'New BetterDesk Support');
+ els['gen-name'].value = '';
+ els['gen-slug'].value = '';
+ writeBranding(connectionDefaults);
+ selectAllPlatforms();
+ els['gen-download-info'].classList.add('hidden');
+ els['gen-revoke-btn'].classList.add('hidden');
+ els['gen-delete-btn'].classList.add('hidden');
+ els['gen-rebuild-btn'].classList.add('hidden');
+ showValidation([]);
+ renderBuilds();
+ markDirty();
+ renderBundleList();
+ updateDownloadLinkPreview();
}
- function fmtDate(iso) {
- if (!iso) return '';
- const d = new Date(iso);
- if (isNaN(d.getTime())) return iso;
- return d.toLocaleDateString();
+ async function openBundle(id) {
+ try {
+ const data = await api('GET', `/api/generator/bundles/${id}`);
+ const bundle = data.data.bundle;
+ state.currentId = bundle.bundle_id;
+ state.currentBundle = bundle;
+ state.currentBuilds = bundle.builds || [];
+ state.slugManual = true;
+ state.productType = 'betterdesk-support';
+ setEditorVisible(true);
+ els['gen-editor-title'].textContent = bundle.name;
+ els['gen-name'].value = bundle.name || '';
+ els['gen-slug'].value = bundle.slug || '';
+ writeBranding(bundle.branding || {});
+ selectAllPlatforms();
+ els['gen-download-info'].classList.remove('hidden');
+ els['gen-revoke-btn'].classList.remove('hidden');
+ els['gen-delete-btn'].classList.remove('hidden');
+ els['gen-rebuild-btn'].classList.remove('hidden');
+ els['gen-revoke-btn'].textContent = bundle.revoked
+ ? t('generator.unrevoke', 'Restore')
+ : t('generator.revoke', 'Revoke');
+ showValidation([]);
+ clearDirty();
+ renderBuilds();
+ renderBundleList();
+ updateDownloadLinkPreview();
+ startBuildsPoll();
+ } catch (err) {
+ notify.error(err.message);
+ }
}
- function platformKey(p, a, f) {
- return `${p}/${a}/${f}`;
- }
-
- function platformLabel(p, a, f) {
- return state.platformLabels[platformKey(p, a, f)]
- || `${p} ${a} ${f}`;
- }
-
- function statusLabel(status) {
- const map = {
- ready: t('generator.build_status_ready', 'Ready'),
- pending: t('generator.build_status_pending', 'Queued'),
- queued: t('generator.build_status_pending', 'Queued'),
- building: t('generator.build_status_building', 'Building'),
- failed: t('generator.build_status_failed', 'Failed'),
+ async function saveBundle() {
+ const name = els['gen-name'].value.trim();
+ if (!name) {
+ notify.error(t('generator.errors.name_required', 'Name is required'));
+ return;
+ }
+ const payload = {
+ name,
+ slug: readSlugInput(),
+ product_type: 'betterdesk-support',
+ branding: readBranding(),
+ platforms: selectedPlatformPayload(),
};
- return map[status] || status;
- }
-
- function summarizeBuilds(builds) {
- const counts = { ready: 0, pending: 0, building: 0, failed: 0 };
- for (const b of builds || []) {
- const status = b.status === 'queued' ? 'pending' : b.status;
- if (counts[status] != null) counts[status]++;
+ try {
+ let data;
+ if (state.currentId === 'new') {
+ data = await api('POST', '/api/generator/bundles', payload);
+ } else {
+ data = await api('PUT', `/api/generator/bundles/${state.currentId}`, payload);
+ }
+ notify.success(t('generator.saved', 'Saved'));
+ showValidation([]);
+ await loadBundles();
+ await openBundle(data.data.bundle.bundle_id);
+ } catch (err) {
+ const details = err.data && (err.data.details || err.data.errors);
+ if (details) showValidation(details);
+ notify.error(err.message);
}
- return counts;
}
- function buildsNeedPoll(builds) {
- return (builds || []).some(
- (b) => b.status === 'queued' || b.status === 'pending' || b.status === 'building'
- );
+ async function loadBundles() {
+ const data = await api('GET', '/api/generator/bundles?includeRevoked=1');
+ state.bundles = (data.data && data.data.bundles) || [];
+ renderBundleList();
+ }
+
+ async function loadPlatforms() {
+ const data = await api('GET', '/api/generator/platforms');
+ state.platforms = (data.data && data.data.platforms) || [];
+ selectAllPlatforms();
+ }
+
+ async function loadDefaults() {
+ const data = await api('GET', '/api/generator/defaults');
+ connectionDefaults = Object.assign(connectionDefaults, data.data || {});
+ }
+
+ async function refreshBuilds() {
+ if (!state.currentId || state.currentId === 'new') return;
+ try {
+ const data = await api('GET', `/api/generator/bundles/${state.currentId}`);
+ state.currentBuilds = (data.data.bundle && data.data.bundle.builds) || [];
+ renderBuilds();
+ } catch (_) { /* ignore poll errors */ }
+ }
+
+ function startBuildsPoll() {
+ stopBuildsPoll();
+ state.buildsPollTimer = setInterval(refreshBuilds, 4000);
}
function stopBuildsPoll() {
@@ -294,618 +411,187 @@
}
}
- function scheduleBuildsPoll() {
- stopBuildsPoll();
- if (!state.currentId || state.currentId === 'new') return;
- if (!buildsNeedPoll(state.currentBuilds)) return;
- state.buildsPollTimer = setInterval(() => {
- refreshBuilds().catch(() => {});
- }, 5000);
- }
+ function renderModuleStatus(status) {
+ state.moduleStatus = status;
+ state.moduleReady = !!(status && status.ready);
+ const el = els['gen-module-status'];
+ if (!el) return;
+ const parts = [
+ `Status: ${status.status || 'unknown'}`,
+ status.termsAccepted ? 'Terms accepted' : 'Terms not accepted',
+ status.templatesPresent ? 'Templates present' : 'Templates missing',
+ status.installedVersion ? `Version ${status.installedVersion}` : null,
+ status.signingSeedPresent ? 'Signing seed present' : 'Signing seed missing (plain JSON Phase A)',
+ status.error ? `Error: ${status.error}` : null,
+ ].filter(Boolean);
+ el.textContent = parts.join(' · ');
+ el.classList.toggle('is-error', status.status === 'error');
- function classifyBuildErrorClient(msg) {
- const s = String(msg || '');
- if (/branding signing|sealbranding|refusing to embed plaintext|signed branding profile could not/i.test(s)) {
- return t('generator.toolchain_branding_seal', 'Branding signing failed — check bundle signing key and rebuild');
+ if (els['gen-accept-terms']) {
+ els['gen-accept-terms'].disabled = !!status.termsAccepted;
}
- if (/not in std|Go toolchain|stdlib verification|go:|cannot find package/i.test(s)) {
- return t('generator.toolchain_go', 'Go toolchain missing or unhealthy');
+ if (els['gen-install-module']) {
+ els['gen-install-module'].disabled = !status.termsAccepted || status.status === 'downloading';
+ els['gen-install-module'].textContent = status.status === 'downloading'
+ ? 'Downloading…'
+ : 'Install from GitHub';
}
- if (/wixl|msitools|\.wxs/i.test(s)) {
- return t('generator.toolchain_wixl', 'wixl (msitools) required for Windows .msi builds');
- }
- if (/appimagetool|AppImage|Failed to extract AppImage|could not create symlink/i.test(s)) {
- return t('generator.toolchain_appimage', 'appimagetool required for Linux AppImage builds');
- }
- if (/dpkg-deb|fakeroot|\.deb/i.test(s)) {
- return t('generator.toolchain_deb', 'dpkg-deb / fakeroot required for .deb packages');
- }
- if (/rpmbuild|\.rpm/i.test(s)) {
- return t('generator.toolchain_rpm', 'rpmbuild required for .rpm packages');
- }
- if (/mesa|opengl|libGL|WGL/i.test(s)) {
- return t('generator.toolchain_mesa', 'Mesa/OpenGL support needed for Windows GUI builds');
- }
- if (/mingw|x86_64-w64-mingw|cgo: C compiler|CC=.*mingw/i.test(s)) {
- return t('generator.toolchain_cgo', 'CGO / mingw cross-compiler required for Windows Fyne builds');
- }
- return t('generator.build_error_hint', 'Build error');
- }
-
- function selectAllPlatforms() {
- state.selectedPlatforms = new Set(
- (state.platforms || []).map((p) => platformKey(p.platform, p.arch, p.format))
- );
- renderPlatformChecklist();
- }
-
- function readSelectedPlatforms() {
- const keys = state.selectedPlatforms;
- const list = (state.platforms || []).filter((p) => keys.has(platformKey(p.platform, p.arch, p.format)));
- return list.map((p) => ({ platform: p.platform, arch: p.arch, format: p.format }));
- }
-
- function renderPlatformChecklist() {
- const root = els['gen-platforms'];
- if (!root) return;
- if (!state.platforms.length) {
- root.innerHTML = `${escapeText(t('generator.builds_loading', 'Loading…'))}
`;
- return;
- }
- root.innerHTML = state.platforms.map((p) => {
- const key = platformKey(p.platform, p.arch, p.format);
- const checked = state.selectedPlatforms.has(key) ? 'checked' : '';
- const label = p.label || platformLabel(p.platform, p.arch, p.format);
- return `
-
-
- ${escapeText(label)}
-
- `;
- }).join('');
- root.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
- cb.addEventListener('change', () => {
- const key = cb.dataset.platformKey;
- if (cb.checked) state.selectedPlatforms.add(key);
- else state.selectedPlatforms.delete(key);
- if (state.currentId === 'new' || state.currentId) markDirty();
- });
- });
- }
-
- function renderBuilds(builds) {
- state.currentBuilds = builds || [];
- const listEl = els['gen-builds-list'];
- const summaryEl = els['gen-builds-summary'];
- if (!listEl) return;
-
- if (!builds || !builds.length) {
- listEl.innerHTML = `${escapeText(t('generator.builds_empty', 'No builds queued yet'))}
`;
- summaryEl.classList.add('hidden');
- stopBuildsPoll();
- return;
- }
-
- const counts = summarizeBuilds(builds);
- summaryEl.textContent = t('generator.builds_summary', '{{ready}} ready · {{pending}} queued · {{building}} building · {{failed}} failed')
- .replace('{{ready}}', counts.ready)
- .replace('{{pending}}', counts.pending)
- .replace('{{building}}', counts.building)
- .replace('{{failed}}', counts.failed);
- summaryEl.classList.remove('hidden');
-
- const rows = [...builds].sort((a, b) => {
- const la = platformLabel(a.platform, a.arch, a.format);
- const lb = platformLabel(b.platform, b.arch, b.format);
- return la.localeCompare(lb);
- });
-
- listEl.innerHTML = `
-
-
-
- ${escapeText(t('generator.builds_title', 'Client builds'))}
- ${escapeText(t('common.status', 'Status'))}
-
-
-
-
- ${rows.map(b => {
- const hint = b.error_message
- ? `${escapeText(classifyBuildErrorClient(b.error_message))}
`
- : '';
- const err = b.error_message
- ? `${escapeText(b.error_message)}
`
- : '';
- const retry = b.status === 'failed'
- ? `
- replay
- ${escapeText(t('generator.retry_build', 'Retry'))}
- `
- : '';
- return `
-
- ${escapeText(platformLabel(b.platform, b.arch, b.format))}${hint}${err}
- ${escapeText(statusLabel(b.status))}
- ${retry}
-
- `;
- }).join('')}
-
-
- `;
-
- listEl.querySelectorAll('.gen-retry-build').forEach((btn) => {
- btn.addEventListener('click', () => retryPlatformBuild(
- btn.dataset.platform,
- btn.dataset.arch,
- btn.dataset.format
- ));
- });
-
- scheduleBuildsPoll();
- }
-
- async function retryPlatformBuild(platform, arch, format) {
- if (!state.currentId || state.currentId === 'new') return;
- try {
- const res = await api(
- 'POST',
- `/api/generator/bundles/${encodeURIComponent(state.currentId)}/rebuild/`
- + `${encodeURIComponent(platform)}/${encodeURIComponent(arch)}/${encodeURIComponent(format)}`
- );
- notify.success(t('generator.retry_queued', 'Platform build queued'));
- renderBuilds((res && res.data && res.data.builds) || []);
- } catch (e) {
- notify.error(e.message);
+ if (els['gen-finish-install']) {
+ els['gen-finish-install'].classList.toggle('hidden', !status.ready);
}
}
- async function loadToolchainStatus() {
- const banner = els['gen-toolchain-banner'];
- if (!banner) return;
- try {
- const res = await api('GET', '/api/generator/build-status');
- const d = (res && res.data) || {};
- const issues = [];
- if (!d.workerEnabled) {
- issues.push(t('generator.toolchain_worker_off', 'Agent build worker is disabled'));
- }
- if (!d.goHealthy) {
- issues.push(t('generator.toolchain_go_missing', 'Go is not available'));
- }
- if (!d.mingwGcc) {
- issues.push(t('generator.toolchain_mingw_missing', 'mingw-w64 (x86_64-w64-mingw32-gcc) not found — Windows builds will fail'));
- }
- if (!d.msiBuilder) {
- issues.push(t('generator.toolchain_msi_missing', 'MSI builder (wixl) not found'));
- }
- if (!d.appimagetool) {
- issues.push(t('generator.toolchain_appimage_missing', 'appimagetool not found — AppImage builds will fail'));
- }
- if (d.rebuildPending) {
- issues.push(
- t('generator.rebuild_pending_banner', 'A generator rebuild is pending')
- .replace('{{reason}}', d.rebuildPending.reason || 'update')
- );
- }
- if (issues.length) {
- banner.className = 'toolchain-banner toolchain-banner--warn';
- banner.textContent = issues.join(' · ');
- banner.classList.remove('hidden');
- } else {
- banner.className = 'toolchain-banner toolchain-banner--ok';
- banner.textContent = t('generator.toolchain_banner_ok', 'Build toolchain ready (Go {{go}}).')
- .replace('{{go}}', d.goBin || 'go');
- banner.classList.remove('hidden');
- }
- } catch (_) {
- banner.classList.add('hidden');
- }
+ function showModuleGate(show) {
+ if (els['gen-module-gate']) els['gen-module-gate'].classList.toggle('hidden', !show);
+ if (els['gen-main']) els['gen-main'].classList.toggle('hidden', show);
}
- async function refreshBuilds() {
- if (!state.currentId || state.currentId === 'new') return;
- const res = await api('GET', `/api/generator/bundles/${encodeURIComponent(state.currentId)}`);
- if (res && res.data && res.data.bundle) {
- state.currentBundle = res.data.bundle;
- renderBuilds(res.data.bundle.builds || []);
- }
- }
-
- async function rebuildAllBuilds() {
- if (!state.currentBundle || state.currentId === 'new') return;
- if (!confirm(t('generator.rebuild_confirm', 'Rebuild all platform installers for this bundle?'))) return;
- const btn = els['gen-rebuild-btn'];
- if (btn) {
- btn.disabled = true;
- btn.innerHTML = `sync ${escapeText(t('generator.rebuilding_all', 'Queuing rebuilds…'))}`;
- }
- try {
- const platforms = readSelectedPlatforms();
- const res = await api('POST', `/api/generator/bundles/${encodeURIComponent(state.currentId)}/rebuild`, {
- platforms: platforms.length ? platforms : undefined,
- });
- notify.success(t('generator.rebuild_queued', 'All platform builds queued'));
- renderBuilds((res && res.data && res.data.builds) || []);
- } catch (e) {
- notify.error(e.message);
- } finally {
- if (btn) {
- btn.disabled = false;
- btn.innerHTML = `sync ${escapeText(t('generator.rebuild_all', 'Rebuild all'))}`;
- }
- }
- }
-
- function renderBundleList() {
- const root = els['gen-bundle-list'];
- if (!state.bundles.length) {
- root.innerHTML = `${escapeText(t('generator.no_bundles', 'No bundles yet'))}
`;
- return;
- }
- root.innerHTML = '';
- state.bundles.forEach(bundle => {
- const item = document.createElement('div');
- item.className = 'bundle-item';
- if (bundle.bundle_id === state.currentId) item.classList.add('active');
- item.dataset.bundleId = bundle.bundle_id;
- const revokedBadge = bundle.revoked
- ? `${escapeText(t('generator.revoked', 'Revoked'))} `
- : '';
- const pt = bundle.product_type || 'support-agent';
- const productBadge = (pt === 'support-agent' || pt === 'agent')
- ? `${escapeText(t('generator.product_support_agent', 'Support'))} `
- : pt === 'rdclient'
- ? `${escapeText(t('generator.product_rdclient', 'RdClient'))} `
- : `${escapeText(t('generator.product_agent_client', 'Agent Client'))} `;
- item.innerHTML = `
-
- ${escapeText(bundle.name || bundle.bundle_id)}
- ${productBadge}
- ${revokedBadge}
-
-
- ${escapeText(fmtDate(bundle.created_at))}
- ↓ ${bundle.download_count || 0}
-
- `;
- item.addEventListener('click', () => selectBundle(bundle.bundle_id));
- root.appendChild(item);
- });
- }
-
- async function loadBundles() {
- try {
- const res = await api('GET', '/api/generator/bundles');
- state.bundles = (res && res.data && res.data.bundles) || [];
- renderBundleList();
- } catch (e) {
- notify.error(e.message, t('generator.title', 'Generator'));
- }
- }
-
- function showEditor() {
- els['gen-empty-state'].classList.add('hidden');
- els['gen-editor-form'].classList.remove('hidden');
- }
-
- function hideEditor() {
- els['gen-empty-state'].classList.remove('hidden');
- els['gen-editor-form'].classList.add('hidden');
- els['gen-revoke-btn'].classList.add('hidden');
- els['gen-delete-btn'].classList.add('hidden');
- els['gen-download-info'].classList.add('hidden');
- els['gen-save-btn'].disabled = true;
- }
-
- function setEditorForNew(productType) {
- state.currentId = 'new';
- state.currentBundle = null;
- state.currentBuilds = [];
- state.dirty = false;
- state.slugManual = false;
- state.productType = productType || 'support-agent';
- selectAllPlatforms();
- stopBuildsPoll();
- els['gen-editor-title'].innerHTML = `add_circle ${escapeText(t('generator.support_agent_new_bundle', 'New Support Agent'))}`;
- els['gen-name'].value = '';
- if (els['gen-slug']) els['gen-slug'].value = '';
- writeBranding(DEFAULT_BRANDING);
- if (els['gen-advanced-branding']) els['gen-advanced-branding'].open = false;
- els['gen-revoke-btn'].classList.add('hidden');
- els['gen-delete-btn'].classList.add('hidden');
- els['gen-download-info'].classList.remove('hidden');
- const buildsSection = $('gen-builds-section');
- if (buildsSection) buildsSection.classList.add('hidden');
- els['gen-download-url'].value = '';
- if (els['gen-open-link']) {
- els['gen-open-link'].href = '#';
- els['gen-open-link'].classList.add('disabled');
- }
- els['gen-save-btn'].disabled = false;
- clearErrors();
- showEditor();
- renderBundleList();
- renderPreview();
- els['gen-name'].focus();
- }
-
- function setEditorForBundle(bundle) {
- state.currentId = bundle.bundle_id;
- state.currentBundle = bundle;
- state.productType = bundle.product_type || 'support-agent';
- state.dirty = false;
- state.slugManual = true;
- selectAllPlatforms();
- stopBuildsPoll();
- els['gen-editor-title'].innerHTML = `edit ${escapeText(bundle.name || bundle.bundle_id)}`;
- els['gen-name'].value = bundle.name || '';
- if (els['gen-slug']) els['gen-slug'].value = bundle.slug || bundle.public_id || '';
- writeBranding(bundle.branding);
- if (els['gen-advanced-branding']) {
- const b = bundle.branding || {};
- const hasCustom = !!(b.company_name || b.logo_data_url || b.short_text || b.contact_email
- || b.product_label || b.hide_product_type);
- els['gen-advanced-branding'].open = hasCustom;
- }
- els['gen-revoke-btn'].classList.remove('hidden');
- els['gen-revoke-btn'].innerHTML = bundle.revoked
- ? `undo ${escapeText(t('generator.unrevoke', 'Unrevoke'))}`
- : `block ${escapeText(t('generator.revoke', 'Revoke'))}`;
- els['gen-delete-btn'].classList.remove('hidden');
- els['gen-save-btn'].disabled = true;
- updateDownloadLinkPreview();
- els['gen-download-info'].classList.remove('hidden');
- const buildsSection = $('gen-builds-section');
- if (buildsSection) buildsSection.classList.remove('hidden');
- renderBuilds(bundle.builds || []);
- clearErrors();
- showEditor();
- renderBundleList();
- renderPreview();
- }
-
- async function selectBundle(bundleId) {
- if (state.dirty && !confirm(t('generator.unsaved_confirm', 'Discard unsaved changes?'))) return;
- try {
- const res = await api('GET', `/api/generator/bundles/${encodeURIComponent(bundleId)}`);
- setEditorForBundle(res.data.bundle);
- } catch (e) {
- notify.error(e.message);
- }
- }
-
- function clearErrors() {
- els['gen-validation-errors'].classList.add('hidden');
- els['gen-validation-errors'].innerHTML = '';
- }
-
- function fmtError(key) {
- if (!key) return '';
- const translated = t(`generator.errors.${key}`, null);
- return translated || key;
- }
-
- function showErrors(errors) {
- if (!errors || !errors.length) { clearErrors(); return; }
- const items = errors.map(e => `${escapeText(fmtError(e))} `).join('');
- els['gen-validation-errors'].innerHTML = `
- ${escapeText(t('generator.errors.validation_failed', 'Validation failed'))}
-
- `;
- els['gen-validation-errors'].classList.remove('hidden');
- }
-
- async function saveBundle() {
- clearErrors();
- const branding = readBranding();
- if (!branding.company_name && els['gen-name'].value.trim()) {
- branding.company_name = els['gen-name'].value.trim();
- }
- const platforms = readSelectedPlatforms();
- if (!platforms.length) {
- showErrors([t('generator.errors.platforms_required', 'Select at least one platform to build')]);
- return;
- }
- const payload = {
- name: els['gen-name'].value.trim(),
- slug: readSlugInput(),
- branding,
- product_type: 'support-agent',
- platforms,
- };
- if (!payload.name) {
- showErrors([t('generator.errors.name_required', 'Bundle name is required')]);
- return;
- }
- els['gen-save-btn'].disabled = true;
- try {
- let res;
- if (state.currentId === 'new') {
- res = await api('POST', '/api/generator/bundles', payload);
- notify.success(t('generator.created', 'Bundle created'));
- } else {
- res = await api('PUT', `/api/generator/bundles/${encodeURIComponent(state.currentId)}`, payload);
- notify.success(t('generator.saved', 'Bundle saved'));
- }
- await loadBundles();
- if (res && res.data && res.data.bundle) {
- setEditorForBundle(res.data.bundle);
- refreshBuilds().catch(() => {});
- }
- } catch (e) {
- const errs = (e.data && e.data.errors) || [e.message];
- showErrors(errs);
- els['gen-save-btn'].disabled = false;
- }
- }
-
- async function toggleRevoke() {
- if (!state.currentBundle) return;
- const newState = !state.currentBundle.revoked;
- const confirmMsg = newState
- ? t('generator.confirm_revoke', 'Revoke this bundle? The download link will stop working.')
- : t('generator.confirm_unrevoke', 'Re-enable this bundle?');
- if (!confirm(confirmMsg)) return;
- try {
- const res = await api('POST', `/api/generator/bundles/${encodeURIComponent(state.currentId)}/revoke`, { revoked: newState });
- notify.success(newState ? t('generator.revoked_ok', 'Bundle revoked') : t('generator.unrevoked_ok', 'Bundle re-enabled'));
- await loadBundles();
- if (res && res.data && res.data.bundle) setEditorForBundle(res.data.bundle);
- } catch (e) {
- notify.error(e.message);
- }
- }
-
- async function deleteBundle() {
- if (!state.currentBundle) return;
- if (!confirm(t('generator.confirm_delete', 'Delete this bundle permanently? This cannot be undone.'))) return;
- try {
- await api('DELETE', `/api/generator/bundles/${encodeURIComponent(state.currentId)}`);
- notify.success(t('generator.deleted', 'Bundle deleted'));
- state.currentId = null;
- state.currentBundle = null;
- state.dirty = false;
- hideEditor();
- await loadBundles();
- } catch (e) {
- notify.error(e.message);
- }
- }
-
- function onLogoChange(ev) {
- const file = ev.target.files && ev.target.files[0];
- if (!file) return;
- if (!/^image\//.test(file.type)) {
- notify.error(t('generator.errors.logo_invalid', 'Logo must be an image file'));
- els['gen-logo'].value = '';
- return;
- }
- if (file.size > 256 * 1024) {
- notify.error(t('generator.errors.logo_too_large', 'Logo must be 256KB or smaller'));
- els['gen-logo'].value = '';
- return;
- }
- const reader = new FileReader();
- reader.onload = () => {
- logoDataUrl = reader.result;
- els['gen-logo-clear'].classList.remove('hidden');
- markDirty();
- };
- reader.onerror = () => notify.error(t('generator.errors.logo_read_failed', 'Failed to read logo file'));
- reader.readAsDataURL(file);
- }
-
- function clearLogo() {
- logoDataUrl = '';
- els['gen-logo'].value = '';
- els['gen-logo-clear'].classList.add('hidden');
- markDirty();
- }
-
- function copyDownloadLink() {
- const url = els['gen-download-url'].value;
- if (!url) return;
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(url).then(
- () => notify.success(t('generator.link_copied', 'Link copied')),
- () => fallbackCopy(url)
- );
- } else {
- fallbackCopy(url);
- }
- }
-
- function fallbackCopy(text) {
- els['gen-download-url'].select();
- try {
- document.execCommand('copy');
- notify.success(t('generator.link_copied', 'Link copied'));
- } catch (_) {
- notify.warning(t('generator.copy_failed', 'Could not copy automatically; please copy manually'));
- }
- }
-
- async function loadConnectionDefaults() {
- try {
- const res = await api('GET', '/api/generator/defaults');
- const d = (res && res.data) || {};
- connectionDefaults = {
- server_host: d.server_host || '',
- use_https: d.use_https !== false,
- };
- } catch (_) {
- connectionDefaults = { server_host: '', use_https: true };
- }
- }
-
- async function loadPlatformLabels() {
- try {
- const res = await api('GET', '/api/generator/platforms');
- const platforms = (res && res.data && res.data.platforms) || [];
- state.platforms = platforms;
- state.platformLabels = {};
- platforms.forEach(p => {
- state.platformLabels[platformKey(p.platform, p.arch, p.format)] = p.label;
- });
- selectAllPlatforms();
- } catch (_) {
- state.platforms = [];
- state.platformLabels = {};
- }
+ async function refreshModuleStatus() {
+ const data = await api('GET', '/api/generator/module/status');
+ const status = data.data || {};
+ renderModuleStatus(status);
+ showModuleGate(!status.ready);
+ return status;
}
function bindEvents() {
if (els['gen-new-support']) {
- els['gen-new-support'].addEventListener('click', () => setEditorForNew('support-agent'));
+ els['gen-new-support'].addEventListener('click', () => setEditorForNew());
+ }
+ if (els['gen-save-btn']) {
+ els['gen-save-btn'].addEventListener('click', () => saveBundle());
}
- els['gen-save-btn'].addEventListener('click', saveBundle);
- els['gen-rebuild-btn'].addEventListener('click', rebuildAllBuilds);
- els['gen-revoke-btn'].addEventListener('click', toggleRevoke);
- els['gen-delete-btn'].addEventListener('click', deleteBundle);
- els['gen-logo'].addEventListener('change', onLogoChange);
- els['gen-logo-clear'].addEventListener('click', clearLogo);
- els['gen-copy-link'].addEventListener('click', copyDownloadLink);
-
if (els['gen-name']) {
- els['gen-name'].addEventListener('input', () => {
- syncSlugFromName();
- markDirty();
- });
+ els['gen-name'].addEventListener('input', () => { syncSlugFromName(); markDirty(); });
}
if (els['gen-slug']) {
els['gen-slug'].addEventListener('input', () => {
state.slugManual = true;
- els['gen-slug'].value = els['gen-slug'].value.toLowerCase().replace(/[^a-z0-9-]/g, '');
updateDownloadLinkPreview();
markDirty();
});
}
-
- [ 'gen-company', 'gen-short-text', 'gen-product-label', 'gen-hide-product-type',
- 'gen-email', 'gen-phone', 'gen-url',
- 'gen-server-host', 'gen-use-https',
- 'gen-primary', 'gen-accent', 'gen-bg', 'gen-surface', 'gen-text', 'gen-text-muted', 'gen-status-ready', 'gen-header-text', 'gen-lang', 'gen-unattended'
- ].forEach(id => {
- const el = els[id];
- if (!el) return;
- const evt = (el.tagName === 'SELECT' || el.type === 'checkbox' || el.type === 'color') ? 'change' : 'input';
- el.addEventListener(evt, markDirty);
+ ['gen-app-name', 'gen-server-host', 'gen-relay-host', 'gen-api-port'].forEach((id) => {
+ if (els[id]) els[id].addEventListener('input', markDirty);
});
+ if (els['gen-use-https']) els['gen-use-https'].addEventListener('change', markDirty);
+
+ if (els['gen-copy-link']) {
+ els['gen-copy-link'].addEventListener('click', async () => {
+ try {
+ await navigator.clipboard.writeText(els['gen-download-url'].value);
+ notify.success(t('common.copied', 'Copied'));
+ } catch (_) {
+ notify.error(t('common.copy_failed', 'Copy failed'));
+ }
+ });
+ }
+
+ if (els['gen-revoke-btn']) {
+ els['gen-revoke-btn'].addEventListener('click', async () => {
+ if (!state.currentId || state.currentId === 'new') return;
+ const revoked = !(state.currentBundle && state.currentBundle.revoked);
+ try {
+ await api('POST', `/api/generator/bundles/${state.currentId}/revoke`, { revoked });
+ notify.success(revoked ? t('generator.revoked', 'Revoked') : t('generator.restored', 'Restored'));
+ await loadBundles();
+ await openBundle(state.currentId);
+ } catch (err) {
+ notify.error(err.message);
+ }
+ });
+ }
+
+ if (els['gen-delete-btn']) {
+ els['gen-delete-btn'].addEventListener('click', async () => {
+ if (!state.currentId || state.currentId === 'new') return;
+ if (!window.confirm(t('generator.confirm_delete', 'Delete this bundle?'))) return;
+ try {
+ await api('DELETE', `/api/generator/bundles/${state.currentId}`);
+ notify.success(t('common.deleted', 'Deleted'));
+ state.currentId = null;
+ setEditorVisible(false);
+ stopBuildsPoll();
+ await loadBundles();
+ } catch (err) {
+ notify.error(err.message);
+ }
+ });
+ }
+
+ if (els['gen-rebuild-btn']) {
+ els['gen-rebuild-btn'].addEventListener('click', async () => {
+ if (!state.currentId || state.currentId === 'new') return;
+ try {
+ await api('POST', `/api/generator/bundles/${state.currentId}/rebuild`, {
+ platforms: selectedPlatformPayload(),
+ });
+ notify.success(t('generator.rebuild_queued', 'Rebuild queued'));
+ await refreshBuilds();
+ startBuildsPoll();
+ } catch (err) {
+ notify.error(err.message);
+ }
+ });
+ }
+
+ if (els['gen-accept-terms']) {
+ els['gen-accept-terms'].addEventListener('click', async () => {
+ try {
+ await api('POST', '/api/generator/module/accept-terms');
+ notify.success('Terms accepted');
+ await refreshModuleStatus();
+ } catch (err) {
+ notify.error(err.message);
+ }
+ });
+ }
+
+ if (els['gen-install-module']) {
+ els['gen-install-module'].addEventListener('click', async () => {
+ try {
+ els['gen-install-module'].disabled = true;
+ notify.info('Downloading templates from GitHub…');
+ await api('POST', '/api/generator/module/install', {});
+ notify.success('Module installed');
+ await refreshModuleStatus();
+ } catch (err) {
+ notify.error(err.message);
+ await refreshModuleStatus();
+ }
+ });
+ }
+
+ if (els['gen-finish-install']) {
+ els['gen-finish-install'].addEventListener('click', async () => {
+ const status = await refreshModuleStatus();
+ if (status.ready) {
+ showModuleGate(false);
+ await initGeneratorMain();
+ }
+ });
+ }
+ }
+
+ async function initGeneratorMain() {
+ await loadDefaults();
+ await loadPlatforms();
+ await loadBundles();
}
async function init() {
cacheEls();
- if (!els['gen-bundle-list']) return;
bindEvents();
- await loadConnectionDefaults();
- await loadPlatformLabels();
- loadToolchainStatus().catch(() => {});
- loadBundles();
+ try {
+ const status = await refreshModuleStatus();
+ if (status.ready) {
+ showModuleGate(false);
+ await initGeneratorMain();
+ }
+ } catch (err) {
+ notify.error(err.message);
+ showModuleGate(true);
+ }
}
if (document.readyState === 'loading') {
diff --git a/web-nodejs/routes/generator.routes.js b/web-nodejs/routes/generator.routes.js
index 6339605c..948b5bf4 100644
--- a/web-nodejs/routes/generator.routes.js
+++ b/web-nodejs/routes/generator.routes.js
@@ -1,12 +1,11 @@
/**
- * BetterDesk Console — Agent Generator routes
+ * BetterDesk Console — Support Generator routes
*
* Provides:
- * - "Generator Agenta" admin panel (/generator) — branding editor + bundle list
+ * - Generator admin panel (/generator) — module install + bundle editor
+ * - Module install API (/api/generator/module/*)
* - Bundle management REST API (/api/generator/bundles/*)
- * - Public download portal per bundle (/d/:slug) with platform cards
- * - Legacy RustDesk TOML config generator (kept for backward compat,
- * marked deprecated in code only — UI no longer exposes it)
+ * - Public download portal per bundle (/d/:slug)
*/
'use strict';
@@ -16,14 +15,13 @@ const router = express.Router();
const { requireAuth, requireAdmin } = require('../middleware/auth');
const keyService = require('../services/keyService');
const bundleService = require('../services/agentBundleService');
-const buildWorker = require('../services/agentBuildWorker');
-const agentClientBuildWorker = require('../services/agentClientBuildWorker');
-const rdclientBuildWorker = require('../services/rdclientBuildWorker');
+const clientTemplateWorker = require('../services/clientTemplateWorker');
+const supportModule = require('../services/supportGeneratorModule');
const db = require('../services/database');
const config = require('../config/config');
const brandingService = require('../services/brandingService');
const conn = require('../services/agentBundleConnection');
-const supportProfile = require('../services/supportAgentProfile');
+const clientConfigHost = require('../services/clientConfigHost');
const { PRODUCT_TYPES, normalizeProductType } = require('../lib/generatorBuildTypes');
// Branding payloads may carry a base64-encoded logo up to 10 MB; expand the
@@ -85,27 +83,47 @@ async function resolveBundleSlug({ preferred, name, fallbackId, excludeBundleId
return { ok: true, slug };
}
-function injectServerBranding(input) {
- // Legacy alias — use finalizeBundleBranding for new bundles.
- return supportProfile.finalizeBundleBrandingSync(input);
-}
-
-function finalizeBundleBrandingSync(input) {
- return supportProfile.finalizeBundleBrandingSync(input);
-}
-
-function addSupportProfileValidity(branding, now = new Date()) {
- return supportProfile.addSupportProfileValidity(branding, now);
-}
-
/**
- * Merge operator connection settings and inject server key.
- * Support-agent bundles do NOT embed a shared enrollment token — each
- * installation registers on its own and receives a unique device_token
- * after operator approval (managed enrollment).
+ * Inject server host / API / public key for BetterDesk Support custom.txt builds.
+ * Branding colors/logos are no longer baked into installers — Client Branding API
+ * supplies runtime appearance.
*/
-async function finalizeBundleBranding(input) {
- return supportProfile.refreshSupportAgentBranding(input);
+async function finalizeSupportBranding(input) {
+ const src = input || {};
+ const branding = { ...src };
+ const hostNorm = conn.normalizeServerHost(
+ branding.server_host || clientConfigHost.resolveClientFacingHost?.() || conn.defaultServerHost()
+ );
+ const host = hostNorm.valid ? hostNorm.host : String(branding.server_host || '').trim();
+ const useHttps = !!(branding.use_https ?? true);
+ const apiPort = String(branding.api_port || conn.defaultApiPort());
+ const scheme = useHttps ? 'https' : 'http';
+ const omitPort = (scheme === 'https' && apiPort === '443') || (scheme === 'http' && apiPort === '80');
+ const apiServer = branding.api_server
+ || (omitPort ? `${scheme}://${host}` : `${scheme}://${host}:${apiPort}`);
+ const pubKey = (await keyService.resolvePublicKey()) || branding.public_key || '';
+
+ branding.server_host = host;
+ branding.relay_host = String(branding.relay_host || host).trim();
+ branding.api_server = apiServer;
+ branding.api_port = apiPort;
+ branding.use_https = useHttps;
+ branding.public_key = pubKey;
+ branding.server_key = pubKey;
+ branding.app_name = String(branding.app_name || branding.company_name || 'BetterDesk Support Agent').trim()
+ || 'BetterDesk Support Agent';
+ branding.company_name = String(branding.company_name || branding.app_name).trim();
+ branding.product_name = branding.app_name;
+ branding.disable_settings = branding.disable_settings !== false;
+ branding.server = {
+ address: omitPort ? `${scheme}://${host}` : `${scheme}://${host}:${apiPort}`,
+ api_url: apiServer,
+ public_key: pubKey,
+ };
+ delete branding.enrollment_token;
+ delete branding.has_enrollment_token;
+ delete branding.enrollment_token_masked;
+ return branding;
}
function publicBrandingView(branding) {
@@ -117,11 +135,8 @@ function publicBrandingView(branding) {
return out;
}
-function resolveBuildWorker(productType) {
- const pt = normalizeProductType(productType);
- if (pt === PRODUCT_TYPES.RDCLIENT) return rdclientBuildWorker;
- if (pt === PRODUCT_TYPES.AGENT_CLIENT) return agentClientBuildWorker;
- return buildWorker;
+function resolveBuildWorker() {
+ return clientTemplateWorker;
}
// =========================================================================
@@ -137,6 +152,49 @@ router.get('/generator', requireAuth, requireAdmin, (req, res) => {
});
});
+// =========================================================================
+// Module install gate
+// =========================================================================
+
+router.get('/api/generator/module/status', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const status = await supportModule.getStatus();
+ res.json({ success: true, data: status });
+ } catch (err) {
+ console.error('[generator] module status error:', err);
+ res.status(500).json({ success: false, error: req.t('errors.server_error') });
+ }
+});
+
+router.post('/api/generator/module/accept-terms', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const state = await supportModule.acceptTerms();
+ res.json({ success: true, data: state });
+ } catch (err) {
+ console.error('[generator] accept-terms error:', err);
+ res.status(500).json({ success: false, error: req.t('errors.server_error') });
+ }
+});
+
+router.post('/api/generator/module/install', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const repo = req.body?.repo ? String(req.body.repo).trim() : undefined;
+ const tag = req.body?.tag ? String(req.body.tag).trim() : undefined;
+ const state = await supportModule.installFromGitHub({ repo, tag });
+ res.json({ success: true, data: state });
+ } catch (err) {
+ const code = err.code || '';
+ if (code === 'terms_not_accepted') {
+ return res.status(400).json({ success: false, error: 'terms_not_accepted' });
+ }
+ console.error('[generator] module install error:', err);
+ res.status(500).json({
+ success: false,
+ error: err.message || req.t('errors.server_error'),
+ });
+ }
+});
+
// =========================================================================
// Bundle management API (admin only)
// =========================================================================
@@ -167,28 +225,31 @@ router.get('/api/generator/bundles/:bundleId', requireAuth, requireAdmin, async
});
router.get('/api/generator/defaults', requireAuth, requireAdmin, async (req, res) => {
+ const host = clientConfigHost.resolveClientFacingHost(req) || conn.defaultServerHost();
res.json({
success: true,
data: {
- server_host: conn.defaultServerHost(),
- use_https: true,
+ server_host: host,
+ relay_host: host,
+ use_https: conn.defaultUseHttps(),
api_port: conn.defaultApiPort(),
public_key: (await keyService.resolvePublicKey()) || '',
+ app_name: 'BetterDesk Support Agent',
},
});
});
router.post('/api/generator/bundles', requireAuth, requireAdmin, async (req, res) => {
try {
+ if (!supportModule.isReady()) {
+ return res.status(400).json({ success: false, error: 'module_not_ready' });
+ }
const name = String(req.body.name || '').trim().slice(0, 100);
if (!name) {
return res.status(400).json({ success: false, error: req.t('generator.errors.name_required') });
}
- const productType = normalizeProductType(req.body.product_type, PRODUCT_TYPES.SUPPORT_AGENT);
- const validateFn = productType === PRODUCT_TYPES.RDCLIENT
- ? bundleService.validateRdclientBranding
- : bundleService.validateBranding;
- const { valid, errors, normalized: base } = validateFn(req.body.branding || {});
+ const productType = PRODUCT_TYPES.BETTERDESK_SUPPORT;
+ const { valid, errors, normalized: base } = bundleService.validateBranding(req.body.branding || {});
if (!valid) {
return res.status(400).json({ success: false, error: req.t('generator.errors.validation_failed'), errors, details: errors });
}
@@ -206,18 +267,8 @@ router.post('/api/generator/bundles', requireAuth, requireAdmin, async (req, res
details: [slugResult.error],
});
}
- const normalized = productType === PRODUCT_TYPES.RDCLIENT
- ? { ...base, bundle_id: bundleId, server_url: base.panel_url }
- : await finalizeBundleBranding(base);
- if (productType !== PRODUCT_TYPES.RDCLIENT) {
- normalized.bundle_id = bundleId;
- normalized.product_name = productType === PRODUCT_TYPES.AGENT_CLIENT
- ? (normalized.company_name ? `${normalized.company_name} Agent` : 'BetterDesk Agent')
- : (normalized.company_name || 'BetterDesk Support');
- if (productType === PRODUCT_TYPES.SUPPORT_AGENT) {
- addSupportProfileValidity(normalized);
- }
- }
+ const normalized = await finalizeSupportBranding(base);
+ normalized.bundle_id = bundleId;
const brandingHash = bundleService.hashBranding(normalized);
const created = await db.createAgentBundle({
bundleId,
@@ -229,7 +280,7 @@ router.post('/api/generator/bundles', requireAuth, requireAdmin, async (req, res
productType,
});
const platformsFilter = Array.isArray(req.body.platforms) ? req.body.platforms : null;
- resolveBuildWorker(productType).enqueueBuildsForHash(brandingHash, {
+ resolveBuildWorker().enqueueBuildsForHash(brandingHash, {
platforms: platformsFilter,
}).catch((e) => {
console.error('[generator] enqueue builds failed:', e.message);
@@ -243,33 +294,22 @@ router.post('/api/generator/bundles', requireAuth, requireAdmin, async (req, res
router.put('/api/generator/bundles/:bundleId', requireAuth, requireAdmin, async (req, res) => {
try {
+ if (!supportModule.isReady()) {
+ return res.status(400).json({ success: false, error: 'module_not_ready' });
+ }
const existing = await db.getAgentBundle(req.params.bundleId);
if (!existing) return res.status(404).json({ success: false, error: req.t('errors.not_found') });
const name = String(req.body.name || existing.name).trim().slice(0, 100);
if (!name) {
return res.status(400).json({ success: false, error: req.t('generator.errors.name_required') });
}
- const productType = normalizeProductType(existing.product_type);
const existingBranding = parseBranding(existing.branding);
- const validateFn = productType === PRODUCT_TYPES.RDCLIENT
- ? bundleService.validateRdclientBranding
- : bundleService.validateBranding;
- const { valid, errors, normalized: base } = validateFn(req.body.branding || existingBranding);
+ const { valid, errors, normalized: base } = bundleService.validateBranding(req.body.branding || existingBranding);
if (!valid) {
return res.status(400).json({ success: false, error: req.t('generator.errors.validation_failed'), errors, details: errors });
}
- const normalized = productType === PRODUCT_TYPES.RDCLIENT
- ? { ...base, bundle_id: req.params.bundleId, server_url: base.panel_url }
- : await finalizeBundleBranding(base);
- if (productType !== PRODUCT_TYPES.RDCLIENT) {
- normalized.bundle_id = req.params.bundleId;
- normalized.product_name = productType === PRODUCT_TYPES.AGENT_CLIENT
- ? (normalized.company_name ? `${normalized.company_name} Agent` : 'BetterDesk Agent')
- : (normalized.company_name || 'BetterDesk Support');
- if (productType === PRODUCT_TYPES.SUPPORT_AGENT) {
- addSupportProfileValidity(normalized);
- }
- }
+ const normalized = await finalizeSupportBranding(base);
+ normalized.bundle_id = req.params.bundleId;
const brandingHash = bundleService.hashBranding(normalized);
let slug = existing.slug || '';
if (req.body.slug !== undefined) {
@@ -303,11 +343,9 @@ router.put('/api/generator/bundles/:bundleId', requireAuth, requireAdmin, async
branding: JSON.stringify(normalized),
brandingHash,
});
- // Phase 2: if branding hash changed, queue new builds; cached artifacts
- // for the previous hash remain reusable for prior portal links.
if (existing.branding_hash !== brandingHash) {
const platformsFilter = Array.isArray(req.body.platforms) ? req.body.platforms : null;
- resolveBuildWorker(existing.product_type).enqueueBuildsForHash(brandingHash, {
+ resolveBuildWorker().enqueueBuildsForHash(brandingHash, {
platforms: platformsFilter,
}).catch((e) => {
console.error('[generator] enqueue builds failed:', e.message);
@@ -327,8 +365,11 @@ router.post('/api/generator/bundles/:bundleId/rebuild', requireAuth, requireAdmi
if (row.revoked) {
return res.status(400).json({ success: false, error: req.t('generator.errors.rebuild_revoked') });
}
+ if (!supportModule.isReady()) {
+ return res.status(400).json({ success: false, error: 'module_not_ready' });
+ }
const platformsFilter = Array.isArray(req.body?.platforms) ? req.body.platforms : null;
- const result = await resolveBuildWorker(row.product_type).rebuildBundleById(
+ const result = await resolveBuildWorker().rebuildBundleById(
req.params.bundleId,
platformsFilter ? { platforms: platformsFilter } : undefined
);
@@ -364,9 +405,11 @@ router.post(
if (!row.branding_hash) {
return res.status(400).json({ success: false, error: req.t('generator.errors.missing_hash') });
}
- const worker = resolveBuildWorker(row.product_type);
- const requeueFn = worker.requeuePlatformBuild || buildWorker.requeuePlatformBuild;
- const result = await requeueFn(
+ if (!supportModule.isReady()) {
+ return res.status(400).json({ success: false, error: 'module_not_ready' });
+ }
+ const worker = resolveBuildWorker();
+ const result = await worker.requeuePlatformBuild(
row.branding_hash,
req.params.platform,
req.params.arch,
@@ -390,7 +433,7 @@ router.post(
router.get('/api/generator/build-status', requireAuth, requireAdmin, (req, res) => {
try {
- const status = buildWorker.getBuildWorkerStatus();
+ const status = clientTemplateWorker.getBuildWorkerStatus();
res.json({ success: true, data: status });
} catch (err) {
console.error('[generator] build-status error:', err);
@@ -421,14 +464,9 @@ router.delete('/api/generator/bundles/:bundleId', requireAuth, requireAdmin, asy
}
});
-/**
- * Live preview helper — validate + normalize a branding payload without
- * persisting it. Used by the editor to render the preview without writing
- * to the DB on every keystroke.
- */
-router.post('/api/generator/preview', requireAuth, requireAdmin, (req, res) => {
+router.post('/api/generator/preview', requireAuth, requireAdmin, async (req, res) => {
try {
- const rawBranding = finalizeBundleBrandingSync(req.body.branding || {});
+ const rawBranding = await finalizeSupportBranding(req.body.branding || {});
const { valid, errors, normalized } = bundleService.validateBranding(rawBranding);
res.json({ success: true, data: { valid, errors, branding: publicBrandingView(normalized) }, errors });
} catch (err) {
@@ -445,10 +483,6 @@ router.get('/api/generator/platforms', requireAuth, requireAdmin, (req, res) =>
// Public download portal
// =========================================================================
-/**
- * Public landing page for an issued bundle. No auth — the slug (or legacy
- * bundle ID) is the access token. Revoked or unknown bundles return 404.
- */
router.get('/d/:publicId', async (req, res) => {
try {
const row = await resolvePublicBundle(req.params.publicId);
@@ -468,8 +502,6 @@ router.get('/d/:publicId', async (req, res) => {
...p,
status: buildMap[`${p.platform}/${p.arch}/${p.format}`] || 'pending',
}));
- // Global console branding provides portal-wide defaults (wallpaper,
- // attribution) shared by every bundle that does not override them.
const gb = brandingService.getBranding();
const globalBranding = {
background: brandingService.buildBackgroundValue(gb.agentBgType, gb.agentBgColor, gb.agentBgGradient, gb.agentBgImageUrl),
@@ -490,10 +522,6 @@ router.get('/d/:publicId', async (req, res) => {
}
});
-/**
- * Public manifest endpoint — JSON shape the portal page polls to refresh
- * platform build status without a full reload.
- */
router.get('/api/d/:publicId/manifest', async (req, res) => {
try {
const row = await resolvePublicBundle(req.params.publicId);
@@ -527,11 +555,6 @@ router.get('/api/d/:publicId/manifest', async (req, res) => {
}
});
-/**
- * Public download endpoint. Phase 1 returns 503 with `build_pending` until
- * the Phase 2 build pipeline is wired; the route exists so the portal can
- * link to it today and Phase 2 is a pure backend swap.
- */
router.get('/api/d/:publicId/download/:platform/:arch/:format', async (req, res) => {
try {
const row = await resolvePublicBundle(req.params.publicId);
diff --git a/web-nodejs/server.js b/web-nodejs/server.js
index 7b420953..8471b93e 100644
--- a/web-nodejs/server.js
+++ b/web-nodejs/server.js
@@ -585,31 +585,14 @@ async function startServer() {
// Defer build workers until after listen + event-bus WS connect settle
// (#353): toolchain/DB work racing native addon init can abort Node 24.
setImmediate(() => {
- // Start branded agent installer build worker (Generator Agenta / Phase 2).
- // Disabled when AGENT_BUILD_WORKER=off — useful for hosts without the
- // build toolchain (e.g. small consoles that only proxy to a build node).
+ // BetterDesk Support Generator — patches Client templates with custom.txt.
+ // Disabled when AGENT_BUILD_WORKER=off (small consoles without templates).
if (process.env.AGENT_BUILD_WORKER !== 'off') {
try {
- const agentBuildWorker = require('./services/agentBuildWorker');
- agentBuildWorker.startWorker();
+ const clientTemplateWorker = require('./services/clientTemplateWorker');
+ clientTemplateWorker.startWorker();
} catch (err) {
- 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);
- }
- }
- if (process.env.AGENT_CLIENT_BUILD_WORKER !== 'off') {
- try {
- const agentClientBuildWorker = require('./services/agentClientBuildWorker');
- agentClientBuildWorker.startWorker();
- } catch (err) {
- console.warn('[server] agent-client build worker disabled:', err.message);
+ console.warn('[server] client template worker disabled:', err.message);
}
}
});
diff --git a/web-nodejs/services/agentBuildWorker.js b/web-nodejs/services/agentBuildWorker.js
deleted file mode 100644
index c85d9793..00000000
--- a/web-nodejs/services/agentBuildWorker.js
+++ /dev/null
@@ -1,1541 +0,0 @@
-/**
- * BetterDesk Console — Support Agent Build Worker (Go / Wails UI)
- *
- * Builds branded betterdesk-support-agent binaries for product_type=support-agent.
- * Agent-client (Tauri) builds are handled by agentClientBuildWorker.js.
- */
-
-'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 { resolveBundleSigningKeyFile } = require('./bundleSigningKey');
-const supportProfile = require('./supportAgentProfile');
-const config = require('../config/config');
-const { readProductVersion } = require('../lib/productVersion');
-const {
- PRODUCT_TYPES,
- normalizeProductType,
- isQueuedBuildStatus,
-} = require('../lib/generatorBuildTypes');
-
-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('[agentBuildWorker] could not load build env file:', e.message);
-}
-
-const BUILD_USER = process.env.BUILD_USER || 'betterdesk';
-const BUILD_CACHE_DIR = process.env.BUILD_CACHE_DIR
- || path.join(config.dataDir || '/opt/BetterDeskConsole/data', 'build-cache');
-const GO_MOD_CACHE_DIR = path.join(BUILD_CACHE_DIR, 'gomod');
-const GO_BUILD_CACHE_DIR = path.join(BUILD_CACHE_DIR, 'gocache');
-const WORK_ROOT = path.join(BUILD_CACHE_DIR, 'work');
-const ARTIFACT_ROOT = process.env.AGENT_ARTIFACT_DIR
- || path.join(config.dataDir || '/opt/BetterDeskConsole/data', 'agent-builds');
-const POLL_INTERVAL_MS = parseInt(process.env.AGENT_BUILD_POLL_MS || '5000', 10);
-/** Always 1 — Go/Wails builds are CPU/RAM heavy; platforms run one after another. */
-const WORKER_CONCURRENCY = 1;
-const BUILD_COOLDOWN_MS = parseInt(process.env.AGENT_BUILD_COOLDOWN_MS || '3000', 10);
-const BUILD_TIMEOUT_MS = parseInt(process.env.AGENT_BUILD_TIMEOUT_MS || (30 * 60 * 1000), 10);
-const BUILD_ORDER = (bundleService.PLATFORMS || []).map(
- (p) => `${p.platform}/${p.arch}/${p.format}`
-);
-const IS_WINDOWS = process.platform === 'win32';
-/** Monorepo root when developing from git; may be wrong on flat console deploys. */
-const PROJECT_ROOT = path.resolve(__dirname, '..', '..');
-/** Console install root (`web-nodejs/` in git, `/opt/BetterDeskConsole` when flattened). */
-const CONSOLE_ROOT = path.resolve(__dirname, '..');
-const VENDORED_GO_BIN = path.join(
- config.dataDir || path.join(__dirname, '..', 'data'),
- 'go-toolchain', 'go', 'bin', IS_WINDOWS ? 'go.exe' : 'go'
-);
-const MESA_DIR_CANDIDATES = [
- path.join(config.dataDir || path.join(__dirname, '..', 'data'), 'mesa-win64'),
- path.join(__dirname, '..', 'vendor', 'mesa-win64'),
-];
-/** Mesa opengl32.dll alone is unloadable without libgallium_wgl.dll — never ship incomplete sets. */
-const MESA_REQUIRED_DLLS = ['opengl32.dll', 'libgallium_wgl.dll'];
-
-function _mesaDirPath() {
- for (const dir of MESA_DIR_CANDIDATES) {
- if (MESA_REQUIRED_DLLS.every((name) => fs.existsSync(path.join(dir, name)))) {
- return dir;
- }
- }
- return null;
-}
-
-function _mesaDllPath() {
- const dir = _mesaDirPath();
- return dir ? path.join(dir, 'opengl32.dll') : null;
-}
-
-function _mesaCompanionFiles() {
- const dir = _mesaDirPath();
- if (!dir) return [];
- return MESA_REQUIRED_DLLS.map((name) => ({
- name,
- src: path.join(dir, name),
- }));
-}
-
-/** True when legacy Fyne dual X11/Wayland artifacts are present alongside the launcher. */
-function _hasDualLinuxUI(distDir) {
- return fs.existsSync(path.join(distDir, 'betterdesk-support-x11'))
- && fs.existsSync(path.join(distDir, 'betterdesk-support-wayland'));
-}
-
-/**
- * Stage Linux UI binaries into a package directory.
- * Wails (default) ships a single binary; Fyne dual layout is used only when
- * betterdesk-support-x11 + betterdesk-support-wayland exist in distDir.
- * @returns {'single'|'dual'}
- */
-async function _stageLinuxUI(distDir, stageDir, launcherName) {
- const launcher = path.join(distDir, launcherName);
- await fsp.copyFile(launcher, path.join(stageDir, launcherName));
- await fsp.chmod(path.join(stageDir, launcherName), 0o755);
- if (!_hasDualLinuxUI(distDir)) {
- return 'single';
- }
- const x11 = path.join(distDir, 'betterdesk-support-x11');
- const wl = path.join(distDir, 'betterdesk-support-wayland');
- await fsp.copyFile(x11, path.join(stageDir, 'betterdesk-support-x11'));
- await fsp.chmod(path.join(stageDir, 'betterdesk-support-x11'), 0o755);
- await fsp.copyFile(wl, path.join(stageDir, 'betterdesk-support-wayland'));
- await fsp.chmod(path.join(stageDir, 'betterdesk-support-wayland'), 0o755);
- return 'dual';
-}
-
-function _resolveBin(candidates) {
- for (const c of candidates) { if (fs.existsSync(c)) return c; }
- return candidates[0];
-}
-
-function _goBinaryHealthy(goBin) {
- if (!goBin || !fs.existsSync(goBin)) return false;
- try {
- const { goStdlibHealthy } = require('./updateService');
- return goStdlibHealthy(goBin);
- } catch {
- return false;
- }
-}
-
-/** Prefer a working Go toolchain (vendored console install beats broken /usr/local/go). */
-function _resolveGoBin() {
- const fromEnv = process.env.GO_BIN;
- const candidates = [
- ...(fromEnv && _goBinaryHealthy(fromEnv) ? [fromEnv] : []),
- VENDORED_GO_BIN,
- '/usr/local/go/bin/go',
- '/usr/bin/go',
- ];
- for (const c of candidates) {
- if (_goBinaryHealthy(c)) return c;
- }
- return null;
-}
-
-let _activeGoBin = _resolveGoBin();
-
-function getGoBin() {
- if (_activeGoBin && _goBinaryHealthy(_activeGoBin)) return _activeGoBin;
- _activeGoBin = _resolveGoBin();
- return _activeGoBin;
-}
-
-let _ensureGoPromise = null;
-
-async function _ensureGoToolchain() {
- if (_goBinaryHealthy(getGoBin())) return getGoBin();
- if (!_ensureGoPromise) {
- _ensureGoPromise = (async () => {
- const updateService = require('./updateService');
- const result = await updateService.installGoToolchain(null, { maxVersion: '1.26.6' });
- if (result.success && result.binPath && _goBinaryHealthy(result.binPath)) {
- _activeGoBin = result.binPath;
- process.env.GO_BIN = result.binPath;
- console.log(`[agentBuildWorker] Go toolchain ready: ${result.version || result.binPath}`);
- return _activeGoBin;
- }
- throw new Error(result.error || 'Go toolchain install failed');
- })().finally(() => {
- _ensureGoPromise = null;
- });
- }
- return _ensureGoPromise;
-}
-
-const BASE_BUILD_ENV = {
- CGO_ENABLED: '1',
- GOMODCACHE: GO_MOD_CACHE_DIR,
- GOCACHE: GO_BUILD_CACHE_DIR,
-};
-
-function _buildEnv(extra = {}) {
- const goBin = getGoBin();
- if (!goBin) {
- throw new Error('Go toolchain not available');
- }
- const goDir = path.dirname(goBin);
- const goroot = path.dirname(goDir);
- return {
- ...BASE_BUILD_ENV,
- ...extra,
- GO_BIN: goBin,
- GOROOT: goroot,
- HOME: BUILD_CACHE_DIR,
- PATH: `${goDir}:/usr/bin:/bin:${process.env.PATH || ''}`,
- };
-}
-
-function _resolveSourceRoot() {
- if (process.env.AGENT_SOURCE_DIR) return process.env.AGENT_SOURCE_DIR;
- const candidates = [
- '/opt/BetterDeskConsole/agent-source/betterdesk-support-agent',
- path.resolve(__dirname, '..', '..', 'betterdesk-support-agent'),
- path.resolve(process.cwd(), 'betterdesk-support-agent'),
- ];
- for (const c of candidates) {
- if (fs.existsSync(path.join(c, 'build.sh'))) return c;
- }
- return candidates[0];
-}
-
-function _resolveAgentLibRoot() {
- const candidates = [
- path.join(path.dirname(SOURCE_ROOT), 'betterdesk-agent'),
- path.resolve(__dirname, '..', '..', 'betterdesk-agent'),
- ];
- for (const c of candidates) {
- if (fs.existsSync(path.join(c, 'go.mod'))) return c;
- }
- return candidates[1];
-}
-
-function _resolveServerLibRoot() {
- const agentSourceBase = path.dirname(SOURCE_ROOT);
- const consoleRoot = agentSourceBase.endsWith('agent-source')
- ? path.dirname(agentSourceBase)
- : agentSourceBase;
- const candidates = [
- path.join(path.dirname(SOURCE_ROOT), 'betterdesk-server'),
- path.join(consoleRoot, 'betterdesk-server'),
- path.resolve(__dirname, '..', '..', 'betterdesk-server'),
- ];
- for (const c of candidates) {
- if (fs.existsSync(path.join(c, 'go.mod'))) return c;
- }
- return candidates[2];
-}
-
-const SOURCE_ROOT = _resolveSourceRoot();
-const AGENT_LIB_ROOT = _resolveAgentLibRoot();
-const SERVER_LIB_ROOT = _resolveServerLibRoot();
-
-const BUILD_PROFILES = {
- 'windows/x64/portable': { os: 'windows', ext: '.exe', pack: 'exe-portable' },
- 'windows/x64/installed': { os: 'windows', ext: '.msi', pack: 'msi' },
- 'linux/x64/portable': { os: 'linux', ext: '.tar.gz', pack: 'tar-portable' },
- 'linux/x64/appimage': { os: 'linux', ext: '.AppImage', pack: 'appimage' },
- 'linux/x64/installed': { os: 'linux', ext: '.deb', pack: 'deb' },
- 'linux/x64/rpm': { os: 'linux', ext: '.rpm', pack: 'rpm' },
-};
-
-let _running = false;
-let _activeBuilds = 0;
-let _pollHandle = null;
-let _lastBuildFinishedAt = 0;
-let _startupReady = false;
-
-function _buildOrderIndex(row) {
- const key = `${row.platform}/${row.arch}/${row.format}`;
- const idx = BUILD_ORDER.indexOf(key);
- return idx >= 0 ? idx : BUILD_ORDER.length + 1;
-}
-
-function _isSupportAgentBundle(bundle) {
- return normalizeProductType(bundle?.product_type) === PRODUCT_TYPES.SUPPORT_AGENT;
-}
-
-function _resolveProductRootDir() {
- // Flat/packaged console keeps VERSION next to server.js (CONSOLE_ROOT).
- // Git checkout keeps VERSION at the monorepo root (PROJECT_ROOT).
- if (fs.existsSync(path.join(CONSOLE_ROOT, 'VERSION'))
- || fs.existsSync(path.join(CONSOLE_ROOT, 'package.json'))) {
- return CONSOLE_ROOT;
- }
- return PROJECT_ROOT;
-}
-
-function _getSupportAgentBuildVersion(opts = {}) {
- const rootDir = opts.rootDir || _resolveProductRootDir();
- return readProductVersion({
- rootDir,
- consoleDir: opts.consoleDir || CONSOLE_ROOT,
- fallback: '0.1.0',
- });
-}
-
-function _getAgentSourceStamp() {
- try {
- return fs.readFileSync(AGENT_SOURCE_STAMP_FILE, 'utf8').trim() || 'unversioned';
- } catch (_) {
- return 'unversioned';
- }
-}
-
-function _buildFingerprint(
- brandingHash,
- version = _getSupportAgentBuildVersion(),
- signingKeyFingerprint = ''
-) {
- return JSON.stringify({
- brandingHash,
- sourceStamp: _getAgentSourceStamp(),
- version,
- signingKeyFingerprint,
- });
-}
-
-async function _injectSupportAgentVersion(workDir, opts = {}) {
- const version = opts.version || _getSupportAgentBuildVersion(opts);
- const mainPath = path.join(workDir, 'main.go');
- const source = await fsp.readFile(mainPath, 'utf8');
- const next = source.replace(
- /^(\s*var\s+version\s*=\s*)"[^"]*"/m,
- `$1"${version}"`
- );
- if (next === source) {
- // Already at the target version (common when fallback matches placeholder).
- const escaped = String(version).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- if (new RegExp(`^\\s*var\\s+version\\s*=\\s*"${escaped}"`, 'm').test(source)) {
- return version;
- }
- throw new Error('support-agent version variable not found in build workspace');
- }
- await fsp.writeFile(mainPath, next, 'utf8');
- return version;
-}
-
-function _compileRoot(brandingHash, osName) {
- return path.join(WORK_ROOT, brandingHash, osName);
-}
-
-function _escapeXml(text) {
- return String(text || '')
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"');
-}
-
-function _upgradeGuidFromHash(brandingHash) {
- const hex = crypto.createHash('sha256').update(String(brandingHash)).digest('hex');
- return `{${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}}`.toUpperCase();
-}
-
-function _which(cmd) {
- const { execSync } = require('child_process');
- try {
- const found = execSync(`command -v ${cmd} 2>/dev/null`, { encoding: 'utf8' }).trim();
- return found || null;
- } catch (_) {
- return null;
- }
-}
-
-function _resolveMsiBuilder() {
- // wixl compiles .wxs → .msi. msibuild (same msitools package) is a different
- // tool for editing MSI databases and must not be used here.
- const candidates = ['wixl', '/usr/bin/wixl'];
- for (const c of candidates) {
- if (c.includes('/') && fs.existsSync(c)) return c;
- }
- return _which('wixl');
-}
-
-/** Prefer an extracted (non-FUSE) appimagetool so the betterdesk user can run it. */
-function _resolveAppImageTool() {
- const candidates = [
- process.env.APPIMAGETOOL_BIN,
- '/usr/local/lib/appimagetool/AppRun',
- '/usr/local/lib/appimagetool/usr/bin/appimagetool',
- '/usr/local/bin/appimagetool',
- ].filter(Boolean);
- for (const c of candidates) {
- if (fs.existsSync(c)) return c;
- }
- return _which('appimagetool');
-}
-
-function _resolveMingwGcc() {
- const candidates = [
- process.env.MINGW_CC,
- '/usr/bin/x86_64-w64-mingw32-gcc',
- 'x86_64-w64-mingw32-gcc',
- ].filter(Boolean);
- for (const c of candidates) {
- if (c.includes('/') && fs.existsSync(c)) return c;
- if (!c.includes('/')) {
- const found = _which(c);
- if (found) return found;
- }
- }
- return null;
-}
-
-/** Normalize optional platform filter from API ({platform,arch,format}[]). */
-function _filterPlatforms(only) {
- const all = bundleService.PLATFORMS || [];
- if (!Array.isArray(only) || only.length === 0) return all;
- const filtered = all.filter((p) => only.some((o) => (
- String(o.platform || o.os || '') === p.platform
- && String(o.arch || 'x64') === p.arch
- && String(o.format || '') === p.format
- )));
- return filtered.length ? filtered : all;
-}
-
-const REBUILD_FLAG_FILE = path.join(config.dataDir || path.join(__dirname, '..', 'data'), '.agent_rebuild_pending');
-const AGENT_SOURCE_STAMP_FILE = path.join(config.dataDir || path.join(__dirname, '..', 'data'), '.agent_source_sha');
-const AGENT_SOURCE_PREFIXES = [
- 'betterdesk-support-agent/',
- 'betterdesk-agent/',
- 'betterdesk-server/',
-];
-
-function _agentSourceDirs() {
- const supportAgent = SOURCE_ROOT;
- const base = path.dirname(supportAgent);
- return {
- base,
- supportAgent,
- agentLib: path.join(base, 'betterdesk-agent'),
- serverLib: path.join(base, 'betterdesk-server'),
- };
-}
-
-async function enqueueBuildsForHash(brandingHash, { force = false, platforms: onlyPlatforms = null } = {}) {
- if (!brandingHash) throw new Error('brandingHash required');
- const platforms = _filterPlatforms(onlyPlatforms);
- 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: 'queued',
- artifactPath: existing?.artifact_path || null,
- artifactSize: existing?.artifact_size || 0,
- artifactSha256: existing?.artifact_sha256 || null,
- errorMessage: '',
- });
- }
-}
-
-function _parseBundleBranding(raw) {
- if (!raw) return {};
- if (typeof raw === 'object') return raw;
- try {
- return JSON.parse(raw);
- } catch (_) {
- return {};
- }
-}
-
-/**
- * Re-issue signed profile fields when rebuild/requeue would otherwise fail the
- * release-profile gate. Valid profiles keep their branding_hash unchanged.
- */
-async function _ensureFreshSupportProfile(bundleRow) {
- if (!bundleRow || !_isSupportAgentBundle(bundleRow)) {
- return {
- brandingHash: bundleRow?.branding_hash || null,
- refreshed: false,
- };
- }
- const branding = _parseBundleBranding(bundleRow.branding);
- if (supportProfile.isReleaseSupportProfileValid(branding)) {
- return { brandingHash: bundleRow.branding_hash, refreshed: false };
- }
-
- const refreshed = await supportProfile.refreshSupportAgentBranding(branding);
- refreshed.bundle_id = bundleRow.bundle_id;
- refreshed.product_name = refreshed.company_name || 'BetterDesk Support';
- supportProfile.addSupportProfileValidity(refreshed);
- const brandingHash = bundleService.hashBranding(refreshed);
- await db.updateAgentBundle(bundleRow.bundle_id, {
- name: bundleRow.name,
- slug: bundleRow.slug,
- branding: JSON.stringify(refreshed),
- brandingHash,
- });
- console.log(
- `[agentBuildWorker] refreshed Support Agent profile for bundle ${bundleRow.bundle_id}`
- + ` (hash ${String(bundleRow.branding_hash || '').slice(0, 8)}… → ${String(brandingHash).slice(0, 8)}…)`
- );
- return { brandingHash, refreshed: true };
-}
-
-/** Queue Support Agent rebuilds after a Support Agent source update. */
-async function requeueAllBundleBuilds() {
- const bundles = await db.listAgentBundles({ includeRevoked: false });
- const hashes = [];
- for (const bundle of bundles) {
- if (bundle.revoked || !_isSupportAgentBundle(bundle)) continue;
- const { brandingHash } = await _ensureFreshSupportProfile(bundle);
- if (!brandingHash) continue;
- hashes.push(brandingHash);
- await enqueueBuildsForHash(brandingHash, { force: true });
- }
- return { bundles: [...new Set(hashes)].length };
-}
-
-/** Force-requeue platform builds for one generator bundle (optional filter). */
-async function rebuildBundleById(bundleId, { platforms: onlyPlatforms = null } = {}) {
- const row = await db.getAgentBundle(bundleId);
- if (!row) return { success: false, error: 'not_found' };
- if (!_isSupportAgentBundle(row)) return { success: false, error: 'not_support_agent' };
- const { brandingHash } = await _ensureFreshSupportProfile(row);
- if (!brandingHash) return { success: false, error: 'missing_hash' };
- const platforms = _filterPlatforms(onlyPlatforms);
- await enqueueBuildsForHash(brandingHash, { force: true, platforms });
- return { success: true, platforms: platforms.length, brandingHash };
-}
-
-/** Re-queue builds that failed only because the host Go install was broken. */
-async function requeueFailedToolchainBuilds() {
- if (!_goBinaryHealthy(getGoBin())) return { requeued: 0 };
-
- const bundles = await db.listAgentBundles({ includeRevoked: false });
- let requeued = 0;
- for (const b of bundles) {
- if (b.revoked || !_isSupportAgentBundle(b)) continue;
- const builds = await db.listAgentBundleBuildsForHash(b.branding_hash);
- for (const row of builds) {
- const err = String(row.error_message || '');
- if (row.status !== 'failed') continue;
- if (!/not in std|Go toolchain|stdlib verification/i.test(err)) continue;
- await db.upsertAgentBundleBuild({
- brandingHash: row.branding_hash,
- platform: row.platform,
- arch: row.arch,
- format: row.format,
- status: 'queued',
- artifactPath: row.artifact_path || null,
- artifactSize: row.artifact_size || 0,
- artifactSha256: row.artifact_sha256 || null,
- errorMessage: '',
- });
- requeued++;
- }
- }
- if (requeued > 0) {
- console.log(`[agentBuildWorker] requeued ${requeued} failed build(s) after Go toolchain recovery`);
- }
- return { requeued };
-}
-
-function markRebuildPending(reason = 'update', meta = {}) {
- fs.mkdirSync(path.dirname(REBUILD_FLAG_FILE), { recursive: true });
- fs.writeFileSync(REBUILD_FLAG_FILE, JSON.stringify({
- reason,
- at: new Date().toISOString(),
- remoteSHA: meta.remoteSHA || null,
- }));
-}
-
-/**
- * On console startup, stage the exact deployed source before queueing builds.
- * This is deliberately outside updateService.applyUpdate(): the console
- * update can finish and restart before the potentially large agent sync runs.
- */
-async function processPendingRebuildOnStartup() {
- if (!fs.existsSync(REBUILD_FLAG_FILE)) return null;
- let meta = {};
- try {
- meta = JSON.parse(fs.readFileSync(REBUILD_FLAG_FILE, 'utf8'));
- } catch (_) { /* use defaults */ }
-
- const shaFile = path.join(config.dataDir || path.join(__dirname, '..', 'data'), '.update_sha');
- const remoteSHA = meta.remoteSHA || (
- fs.existsSync(shaFile) ? fs.readFileSync(shaFile, 'utf8').trim() : ''
- );
- let source = null;
- if (remoteSHA) {
- const updateService = require('./updateService');
- source = await updateService.syncAgentSourceAtSha(remoteSHA);
- }
-
- // Delete the flag only after source sync and requeue both succeed so a
- // crash mid-update does not lose the pending rebuild.
- const result = await requeueAllBundleBuilds();
- try {
- fs.unlinkSync(REBUILD_FLAG_FILE);
- } catch (_) { /* ok */ }
- console.log(
- `[agentBuildWorker] auto-rebuild queued for ${result.bundles} bundle(s)`
- + (meta.reason ? ` (reason: ${meta.reason})` : '')
- );
- return {
- ...result,
- source,
- remoteSHA: remoteSHA || null,
- reason: meta.reason || 'pending',
- };
-}
-
-/** Classify a build stderr / error_message for UI hints. */
-function classifyBuildError(msg) {
- const s = String(msg || '');
- // Branding seal must win over incidental cgo/gcc noise from a failed go run.
- if (/branding signing|sealbranding|refusing to embed plaintext|signed branding profile could not/i.test(s)) {
- return { kind: 'branding_seal', hintKey: 'generator.toolchain_branding_seal' };
- }
- if (/not in std|Go toolchain|stdlib verification|go:|cannot find package/i.test(s)) {
- return { kind: 'go', hintKey: 'generator.toolchain_go' };
- }
- if (/wixl|msitools|\.wxs/i.test(s)) {
- return { kind: 'wixl', hintKey: 'generator.toolchain_wixl' };
- }
- if (/appimagetool|AppImage|Failed to extract AppImage|could not create symlink/i.test(s)) {
- return { kind: 'appimage', hintKey: 'generator.toolchain_appimage' };
- }
- if (/dpkg-deb|fakeroot|\.deb/i.test(s)) {
- return { kind: 'deb', hintKey: 'generator.toolchain_deb' };
- }
- if (/rpmbuild|\.rpm/i.test(s)) {
- return { kind: 'rpm', hintKey: 'generator.toolchain_rpm' };
- }
- if (/mesa|opengl|libGL|WGL/i.test(s)) {
- return { kind: 'mesa', hintKey: 'generator.toolchain_mesa' };
- }
- if (/mingw|x86_64-w64-mingw|cgo: C compiler|CC=.*mingw/i.test(s)) {
- return { kind: 'cgo', hintKey: 'generator.toolchain_cgo' };
- }
- return { kind: 'compile', hintKey: 'generator.build_error_hint' };
-}
-
-/** Force-requeue a single platform build for a branding hash. */
-async function requeuePlatformBuild(brandingHash, platform, arch, format) {
- if (!brandingHash || !platform || !arch || !format) {
- return { success: false, error: 'missing_args' };
- }
- const allowed = (bundleService.PLATFORMS || []).some(
- (p) => p.platform === platform && p.arch === arch && p.format === format
- );
- if (!allowed) return { success: false, error: 'unsupported_platform' };
-
- const bundle = await _findBundleForHash(brandingHash);
- let hash = brandingHash;
- if (bundle) {
- const ensured = await _ensureFreshSupportProfile(bundle);
- if (ensured.brandingHash) hash = ensured.brandingHash;
- }
-
- await db.upsertAgentBundleBuild({
- brandingHash: hash,
- platform,
- arch,
- format,
- status: 'queued',
- artifactPath: null,
- artifactSize: 0,
- artifactSha256: null,
- errorMessage: '',
- });
- return { success: true, brandingHash: hash };
-}
-
-/** Diagnostics for Generator / Settings panels. */
-function getBuildWorkerStatus() {
- let rebuildPending = null;
- if (fs.existsSync(REBUILD_FLAG_FILE)) {
- try {
- rebuildPending = JSON.parse(fs.readFileSync(REBUILD_FLAG_FILE, 'utf8'));
- } catch (_) {
- rebuildPending = { reason: 'unknown' };
- }
- }
- let sourceStamp = null;
- if (fs.existsSync(AGENT_SOURCE_STAMP_FILE)) {
- try {
- sourceStamp = fs.readFileSync(AGENT_SOURCE_STAMP_FILE, 'utf8').trim();
- } catch (_) { /* ok */ }
- }
- const goBin = getGoBin();
- return {
- workerEnabled: process.env.AGENT_BUILD_WORKER !== 'off',
- goBin,
- goHealthy: _goBinaryHealthy(goBin),
- sourceRoot: SOURCE_ROOT,
- sourceStamp,
- buildVersion: _getSupportAgentBuildVersion(),
- rebuildPending,
- mesaDll: _mesaDllPath() || null,
- msiBuilder: _resolveMsiBuilder(),
- mingwGcc: _resolveMingwGcc(),
- appimagetool: _resolveAppImageTool(),
- platforms: (bundleService.PLATFORMS || []).map((p) => ({
- platform: p.platform,
- arch: p.arch,
- format: p.format,
- label: p.label,
- })),
- };
-}
-
-/**
- * Stage support-agent / betterdesk-agent files downloaded during an in-app update
- * into the build worker source tree (agent-source/).
- */
-async function stageSourcesFromGitHub({ remoteSHA, files, download }) {
- if (!files?.length || typeof download !== 'function') {
- return { staged: 0 };
- }
- const owner = process.env.UPDATE_GITHUB_OWNER || 'UNITRONIX';
- const repo = process.env.UPDATE_GITHUB_REPO || 'BetterDesk';
- let staged = 0;
- for (const file of files) {
- if (file.status === 'removed') continue;
- if (await _writeAgentSourceFile(owner, repo, remoteSHA, file.path, download)) {
- staged++;
- }
- }
- return { staged };
-}
-
-/**
- * Download the full support-agent + betterdesk-agent trees at remoteSHA.
- * Used after updates so agent-source/ stays consistent even when an individual
- * commit diff only touches the build worker or generator routes.
- */
-async function syncFullAgentSourceFromGitHub({ remoteSHA, download, listPaths }) {
- if (typeof download !== 'function' || typeof listPaths !== 'function') {
- throw new Error('download and listPaths are required');
- }
- const owner = process.env.UPDATE_GITHUB_OWNER || 'UNITRONIX';
- const repo = process.env.UPDATE_GITHUB_REPO || 'BetterDesk';
- const allPaths = await listPaths(remoteSHA);
- const agentPaths = allPaths.filter((fp) =>
- AGENT_SOURCE_PREFIXES.some((pref) => fp.startsWith(pref))
- );
-
- let staged = 0;
- for (const fp of agentPaths) {
- if (await _writeAgentSourceFile(owner, repo, remoteSHA, fp, download)) {
- staged++;
- }
- }
-
- if (remoteSHA) {
- fs.mkdirSync(path.dirname(AGENT_SOURCE_STAMP_FILE), { recursive: true });
- fs.writeFileSync(AGENT_SOURCE_STAMP_FILE, String(remoteSHA).trim());
- }
- return { staged, paths: agentPaths.length };
-}
-
-async function _writeAgentSourceFile(owner, repo, remoteSHA, fp, download) {
- let destRoot;
- let rel;
- if (fp.startsWith('betterdesk-support-agent/')) {
- destRoot = _agentSourceDirs().supportAgent;
- rel = fp.slice('betterdesk-support-agent/'.length);
- } else if (fp.startsWith('betterdesk-agent/')) {
- destRoot = _agentSourceDirs().agentLib;
- rel = fp.slice('betterdesk-agent/'.length);
- } else if (fp.startsWith('betterdesk-server/')) {
- destRoot = _agentSourceDirs().serverLib;
- rel = fp.slice('betterdesk-server/'.length);
- } else {
- return false;
- }
- if (!rel) return false;
-
- const dest = path.join(destRoot, rel);
- const content = await download(owner, repo, remoteSHA, fp);
- await fsp.mkdir(path.dirname(dest), { recursive: true });
- await fsp.writeFile(dest, content);
- if (!IS_WINDOWS && (rel.endsWith('.sh') || rel === 'build.sh')) {
- try { await fsp.chmod(dest, 0o755); } catch (_) { /* ok */ }
- }
- return true;
-}
-
-/**
- * If agent-source was never stamped or is missing expected files while bundles
- * exist, sync from the deployed commit SHA and queue rebuilds.
- */
-async function reconcileAgentSourceDrift() {
- if (fs.existsSync(REBUILD_FLAG_FILE)) return null;
-
- const bundles = await db.listAgentBundles({ includeRevoked: false });
- const active = bundles.filter((bundle) => !bundle.revoked && _isSupportAgentBundle(bundle));
- if (active.length === 0) return null;
-
- const supportRoot = _agentSourceDirs().supportAgent;
- const serverRoot = _agentSourceDirs().serverLib;
- const missingCore = !fs.existsSync(path.join(supportRoot, 'build.sh'))
- || !fs.existsSync(path.join(supportRoot, 'urls.go'))
- || !fs.existsSync(path.join(serverRoot, 'go.mod'));
- const stampedSha = fs.existsSync(AGENT_SOURCE_STAMP_FILE)
- ? fs.readFileSync(AGENT_SOURCE_STAMP_FILE, 'utf8').trim()
- : '';
-
- if (!missingCore && stampedSha) return null;
-
- const shaFile = path.join(config.dataDir || path.join(__dirname, '..', 'data'), '.update_sha');
- const deployedSha = fs.existsSync(shaFile)
- ? fs.readFileSync(shaFile, 'utf8').trim()
- : '';
-
- markRebuildPending('agent-source drift', { remoteSHA: deployedSha || null });
- return processPendingRebuildOnStartup();
-}
-
-function startWorker() {
- if (_pollHandle) return;
- console.log(`[agentBuildWorker] Go support-agent source=${SOURCE_ROOT} server=${SERVER_LIB_ROOT} go=${getGoBin()}`);
- _startupReady = false;
- _pollHandle = setInterval(() => {
- _tick().catch((e) => console.error('[agentBuildWorker] tick error:', e.message));
- }, POLL_INTERVAL_MS);
- const prepareStartup = async () => {
- try {
- await _ensureDirs();
- await processPendingRebuildOnStartup();
- await reconcileAgentSourceDrift();
- _startupReady = true;
- } catch (e) {
- console.error('[agentBuildWorker] startup preparation failed:', e.message);
- _startupReady = false;
- if (_pollHandle) {
- setTimeout(() => {
- if (_pollHandle && !_startupReady) prepareStartup();
- }, 30000);
- }
- }
- };
- prepareStartup();
- (async () => {
- try {
- await _ensureGoToolchain();
- await requeueFailedToolchainBuilds();
- } catch (e) {
- console.warn('[agentBuildWorker] Go toolchain preflight:', e.message);
- }
- })();
- console.log(`[agentBuildWorker] started (poll ${POLL_INTERVAL_MS}ms)`);
-}
-
-function stopWorker() {
- if (_pollHandle) { clearInterval(_pollHandle); _pollHandle = null; }
- _startupReady = false;
-}
-
-async function getReadyArtifact({ brandingHash, platform, arch, format }) {
- const row = await db.getAgentBundleBuild({ brandingHash, platform, arch, format });
- if (!row || row.status !== 'ready' || !row.artifact_path) return null;
- try {
- await fsp.access(row.artifact_path, fs.constants.R_OK);
- } catch {
- return null;
- }
- return row;
-}
-
-async function _ensureDirs() {
- await fsp.mkdir(WORK_ROOT, { recursive: true });
- await fsp.mkdir(ARTIFACT_ROOT, { recursive: true });
- await fsp.mkdir(GO_MOD_CACHE_DIR, { recursive: true });
- await fsp.mkdir(GO_BUILD_CACHE_DIR, { recursive: true });
-}
-
-async function _tick() {
- if (!_startupReady) return;
- if (_running || _activeBuilds >= WORKER_CONCURRENCY) return;
- if (BUILD_COOLDOWN_MS > 0 && Date.now() - _lastBuildFinishedAt < BUILD_COOLDOWN_MS) {
- return;
- }
- if (await _hasBuildInProgress()) return;
-
- _running = true;
- try {
- const claimed = await _claimNextBuild();
- if (!claimed) return;
- _activeBuilds++;
- try {
- await _runOne(claimed);
- } catch (e) {
- console.error('[agentBuildWorker] build crashed:', e);
- } finally {
- _activeBuilds--;
- _lastBuildFinishedAt = Date.now();
- }
- } finally {
- _running = false;
- }
-}
-
-async function _hasBuildInProgress() {
- if (_activeBuilds > 0) return true;
- const bundles = await db.listAgentBundles();
- for (const b of bundles) {
- if (b.revoked || !_isSupportAgentBundle(b)) continue;
- const builds = await db.listAgentBundleBuildsForHash(b.branding_hash);
- if (builds.some((r) => r.status === 'building')) return true;
- }
- return false;
-}
-
-async function _claimNextBuild() {
- if (await _hasBuildInProgress()) return null;
- const candidates = await _listPendingBuilds(50);
- candidates.sort((a, b) => {
- const order = _buildOrderIndex(a) - _buildOrderIndex(b);
- if (order !== 0) return order;
- return String(a.created_at || '').localeCompare(String(b.created_at || ''));
- });
- for (const row of candidates) {
- const bundleRow = await _findBundleForHash(row.branding_hash);
- if (!bundleRow || !_isSupportAgentBundle(bundleRow)) continue;
- const profile = BUILD_PROFILES[`${row.platform}/${row.arch}/${row.format}`];
- if (!profile) continue;
- 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: '',
- });
- return row;
- }
- return null;
-}
-
-async function _listPendingBuilds(limit) {
- const bundles = await db.listAgentBundles();
- const out = [];
- for (const b of bundles) {
- if (b.revoked || !_isSupportAgentBundle(b)) continue;
- const builds = await db.listAgentBundleBuildsForHash(b.branding_hash);
- for (const r of builds) {
- if (isQueuedBuildStatus(r.status)) out.push(r);
- if (out.length >= limit) break;
- }
- if (out.length >= limit) break;
- }
- out.sort((a, b) => _buildOrderIndex(a) - _buildOrderIndex(b));
- return out.slice(0, limit);
-}
-
-async function _runOne(buildRow) {
- const key = `${buildRow.platform}/${buildRow.arch}/${buildRow.format}`;
- const profile = BUILD_PROFILES[key];
- const startTs = Date.now();
- console.log(`[agentBuildWorker] build start hash=${buildRow.branding_hash.slice(0, 12)} ${key}`);
-
- try {
- const bundleRow = await _findBundleForHash(buildRow.branding_hash);
- if (!bundleRow) throw new Error(`no bundle with hash ${buildRow.branding_hash}`);
-
- const branding = JSON.parse(bundleRow.branding || '{}');
- _assertReleaseSupportProfile(branding);
- const compileDir = _compileRoot(buildRow.branding_hash, profile.os);
- const brandingFile = path.join(compileDir, 'resources', 'branding.json');
- const binaryName = profile.os === 'windows' ? 'betterdesk-support.exe' : 'betterdesk-support';
- const binaryPath = path.join(compileDir, 'dist', binaryName);
- const buildVersion = _getSupportAgentBuildVersion();
- const signingKeyFile = await resolveBundleSigningKeyFile({ keysPath: config.keysPath });
- const signingKeyFingerprint = await _sha256OfFile(signingKeyFile);
- const buildFingerprint = _buildFingerprint(
- buildRow.branding_hash,
- buildVersion,
- signingKeyFingerprint
- );
- const shouldCompile = await _needsCompile(
- compileDir, buildFingerprint, binaryPath, profile.os
- );
-
- await _materialiseWorkspace(compileDir, branding, { refreshSources: shouldCompile });
- await _ensureGoToolchain();
-
- if (shouldCompile) {
- await _injectSupportAgentVersion(compileDir, { version: buildVersion });
- await _runGoBuild(compileDir, brandingFile, binaryPath, profile.os, signingKeyFile);
- await fsp.writeFile(
- path.join(compileDir, '.built_for'),
- buildFingerprint,
- 'utf8'
- );
- } else {
- console.log(`[agentBuildWorker] reusing compiled ${profile.os} binary for ${key}`);
- }
-
- const packed = await _packArtifact(
- compileDir,
- binaryPath,
- profile,
- buildRow.branding_hash.slice(0, 8),
- branding,
- buildRow.branding_hash
- );
- const finalDir = path.join(ARTIFACT_ROOT, buildRow.branding_hash);
- await fsp.mkdir(finalDir, { recursive: true });
- const dest = path.join(finalDir, `${buildRow.platform}-${buildRow.arch}-${buildRow.format}${profile.ext}`);
- await fsp.copyFile(packed, dest);
-
- const stat = await fsp.stat(dest);
- const sha = await _sha256OfFile(dest);
-
- await db.upsertAgentBundleBuild({
- brandingHash: buildRow.branding_hash,
- platform: buildRow.platform,
- arch: buildRow.arch,
- format: buildRow.format,
- status: 'ready',
- artifactPath: dest,
- artifactSize: stat.size,
- artifactSha256: sha,
- errorMessage: '',
- });
- console.log(`[agentBuildWorker] build ready ${key} (${(stat.size / 1024 / 1024).toFixed(2)} MB, ${((Date.now() - startTs) / 1000).toFixed(1)}s)`);
- } catch (err) {
- const msg = (err && err.message) ? err.message.slice(0, 800) : String(err).slice(0, 800);
- console.error(`[agentBuildWorker] build FAILED ${key}: ${msg}`);
- await db.upsertAgentBundleBuild({
- brandingHash: buildRow.branding_hash,
- platform: buildRow.platform,
- arch: buildRow.arch,
- format: buildRow.format,
- status: 'failed',
- artifactPath: null,
- artifactSize: 0,
- artifactSha256: null,
- errorMessage: msg,
- }).catch(() => {});
- }
-}
-
-function _assertReleaseSupportProfile(branding) {
- supportProfile.assertReleaseSupportProfile(branding);
-}
-
-async function _findBundleForHash(hash) {
- const all = await db.listAgentBundles();
- return all.find(b => b.branding_hash === hash) || null;
-}
-
-async function _needsCompile(workDir, buildFingerprint, binaryPath, _targetOS) {
- try {
- const stamp = (await fsp.readFile(path.join(workDir, '.built_for'), 'utf8')).trim();
- if (stamp !== buildFingerprint) return true;
- // Wails ships a single Linux binary; dual X11/Wayland is optional (Fyne).
- await fsp.access(binaryPath, fs.constants.R_OK);
- return false;
- } catch {
- return true;
- }
-}
-
-async function _materialiseWorkspace(workDir, branding, { refreshSources = true } = {}) {
- const mustRefresh = refreshSources || !fs.existsSync(path.join(workDir, 'build.sh'));
- if (mustRefresh) {
- if (fs.existsSync(workDir)) {
- await fsp.rm(workDir, { recursive: true, force: true });
- }
- await fsp.mkdir(workDir, { recursive: true });
- await _copyDir(SOURCE_ROOT, workDir);
- const agentLibDest = path.join(workDir, '..', 'betterdesk-agent');
- if (fs.existsSync(agentLibDest)) {
- await fsp.rm(agentLibDest, { recursive: true, force: true });
- }
- await fsp.mkdir(path.dirname(agentLibDest), { recursive: true });
- await _copyDir(AGENT_LIB_ROOT, agentLibDest);
- const serverLibDest = path.join(workDir, '..', 'betterdesk-server');
- if (fs.existsSync(serverLibDest)) {
- await fsp.rm(serverLibDest, { recursive: true, force: true });
- }
- await fsp.mkdir(path.dirname(serverLibDest), { recursive: true });
- await _copyDir(SERVER_LIB_ROOT, serverLibDest);
- }
- await fsp.mkdir(path.join(workDir, 'resources'), { recursive: true });
- const buildBranding = { ...branding };
- delete buildBranding.enrollment_token;
- delete buildBranding.has_enrollment_token;
- delete buildBranding.enrollment_token_masked;
- await fsp.writeFile(
- path.join(workDir, 'resources', 'branding.json'),
- JSON.stringify(buildBranding, null, 2),
- 'utf8'
- );
-}
-
-async function _copyDir(src, dst) {
- const SKIP = new Set(['node_modules', 'target', 'dist', '.git', 'data']);
- const entries = await fsp.readdir(src, { withFileTypes: true });
- await fsp.mkdir(dst, { recursive: true });
- for (const entry of entries) {
- if (SKIP.has(entry.name)) continue;
- const s = path.join(src, entry.name);
- const d = path.join(dst, entry.name);
- if (entry.isDirectory()) {
- await _copyDir(s, d);
- } else {
- await fsp.copyFile(s, d);
- }
- }
-}
-
-async function _ensureMesaForWindows(workDir) {
- const companions = _mesaCompanionFiles();
- if (!companions.length) {
- console.warn(
- '[agentBuildWorker] complete Mesa set not found (need opengl32.dll + libgallium_wgl.dll) — '
- + 'skipping software OpenGL embed. Run scripts/fetch-mesa-windows.sh'
- );
- return false;
- }
- const destDir = path.join(workDir, 'windows');
- await fsp.mkdir(destDir, { recursive: true });
- for (const f of companions) {
- await fsp.copyFile(f.src, path.join(destDir, f.name));
- }
- return true;
-}
-
-async function _runGoBuild(workDir, brandingPath, outputPath, targetOS, signingKeyFile) {
- await fsp.mkdir(path.dirname(outputPath), { recursive: true });
- if (targetOS === 'windows') {
- await _ensureMesaForWindows(workDir);
- }
- const buildScript = path.join(workDir, 'build.sh');
- const args = ['-b', brandingPath, '-o', outputPath, '-p', targetOS];
- if (targetOS === 'linux') {
- args.push('-d');
- }
- if (!signingKeyFile) {
- throw new Error('Support Agent branding signing key is required');
- }
- await _runProcess('/bin/bash', [buildScript, ...args], {
- cwd: workDir,
- env: { BETTERDESK_BUNDLE_SIGNING_KEY_FILE: signingKeyFile },
- });
- // Wails (default) produces a single Linux binary; dual X11/Wayland only for Fyne.
- await fsp.access(outputPath, fs.constants.R_OK);
-}
-
-async function _packArtifact(workDir, binaryPath, profile, label, branding = {}, brandingHash = label) {
- const packDir = path.join(workDir, 'pack');
- await fsp.mkdir(packDir, { recursive: true });
- const baseName = path.basename(binaryPath);
-
- switch (profile.pack) {
- case 'exe-portable': {
- const out = path.join(packDir, `betterdesk-support-${label}-portable.exe`);
- await fsp.copyFile(binaryPath, out);
- return out;
- }
- case 'msi': {
- const msiBuilder = _resolveMsiBuilder();
- if (!msiBuilder) {
- throw new Error(
- 'wixl not found — install wixl (apt install wixl / dnf install msitools wixl) for Windows MSI builds'
- );
- }
- const msiDir = path.join(packDir, 'msi');
- await fsp.mkdir(msiDir, { recursive: true });
- await fsp.copyFile(binaryPath, path.join(msiDir, 'betterdesk-support.exe'));
- const msiIcon = path.join(msiDir, 'betterdesk-support.ico');
- await _runProcess('go', [
- 'run', './cmd/winicon',
- '-branding', 'resources/branding.json',
- '-out', msiIcon,
- ], { cwd: workDir });
- const mesaFiles = _mesaCompanionFiles();
- let mesaComponent = '';
- let mesaFeatureRef = '';
- if (mesaFiles.length) {
- const componentXml = [];
- const refs = [];
- for (const f of mesaFiles) {
- await fsp.copyFile(f.src, path.join(msiDir, f.name));
- const id = f.name.replace(/[^A-Za-z0-9]/g, '');
- componentXml.push(`
-
-
- `);
- refs.push(`\n `);
- }
- mesaComponent = componentXml.join('');
- mesaFeatureRef = refs.join('');
- }
- const productName = _escapeXml(
- branding.product_name || branding.company_name || 'BetterDesk Support'
- );
- const manufacturer = _escapeXml(branding.company_name || 'BetterDesk');
- const upgradeCode = _upgradeGuidFromHash(brandingHash);
- const wxs = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ${mesaComponent}
-
-
- ${mesaFeatureRef}
-
-
-
-`;
- const wxsPath = path.join(msiDir, 'installer.wxs');
- await fsp.writeFile(wxsPath, wxs, 'utf8');
- const msiPath = path.join(packDir, `betterdesk-support-${label}.msi`);
- await _runProcess(msiBuilder, ['-o', msiPath, wxsPath], { cwd: msiDir, timeoutMs: 10 * 60 * 1000 });
- return msiPath;
- }
- case 'raw': {
- const out = path.join(packDir, baseName);
- await fsp.copyFile(binaryPath, out);
- return out;
- }
- case 'tar-portable': {
- const stage = path.join(packDir, 'stage');
- const distDir = path.dirname(binaryPath);
- await fsp.mkdir(stage, { recursive: true });
- const layout = await _stageLinuxUI(distDir, stage, baseName);
- await fsp.writeFile(path.join(stage, 'portable'), '', 'utf8');
- const readme = layout === 'dual'
- ? (
- 'BetterDesk Support Agent (portable)\r\n\r\n'
- + 'Run ./betterdesk-support — auto-selects Wayland or X11.\r\n'
- + 'Override: BETTERDESK_UI_BACKEND=wayland|x11\r\n'
- )
- : (
- 'BetterDesk Support Agent (portable)\r\n\r\n'
- + 'Run ./betterdesk-support\r\n'
- );
- await fsp.writeFile(path.join(stage, 'README.txt'), readme, 'utf8');
- const tarPath = path.join(packDir, `betterdesk-support-${label}-portable.tar.gz`);
- const tarMembers = layout === 'dual'
- ? ['betterdesk-support', 'betterdesk-support-x11', 'betterdesk-support-wayland', 'portable', 'README.txt']
- : ['betterdesk-support', 'portable', 'README.txt'];
- await _runProcess('tar', ['-czf', tarPath, '-C', stage, ...tarMembers], { cwd: packDir });
- return tarPath;
- }
- case 'deb': {
- const pkgRoot = path.join(packDir, 'pkg');
- const binDir = path.join(pkgRoot, 'usr', 'local', 'bin');
- const libDir = path.join(pkgRoot, 'usr', 'lib', 'betterdesk-support');
- await fsp.mkdir(binDir, { recursive: true });
- await fsp.mkdir(libDir, { recursive: true });
- const distDir = path.dirname(binaryPath);
- const layout = await _stageLinuxUI(distDir, libDir, 'betterdesk-support');
- const binDest = path.join(binDir, 'betterdesk-support');
- if (layout === 'dual') {
- await fsp.writeFile(binDest, `#!/bin/sh
-LIB="/usr/lib/betterdesk-support"
-if [ -n "$WAYLAND_DISPLAY" ] && [ -z "$DISPLAY" ] && [ -x "$LIB/betterdesk-support-wayland" ]; then
- exec "$LIB/betterdesk-support-wayland" "$@"
-fi
-exec "$LIB/betterdesk-support-x11" "$@"
-`, { mode: 0o755 });
- } else {
- await fsp.writeFile(binDest, `#!/bin/sh
-exec /usr/lib/betterdesk-support/betterdesk-support "$@"
-`, { mode: 0o755 });
- }
- const postinst = layout === 'dual'
- ? `#!/bin/sh\n/usr/lib/betterdesk-support/betterdesk-support-x11 -install || true\n`
- : `#!/bin/sh\n/usr/lib/betterdesk-support/betterdesk-support -install || true\n`;
- const debianDir = path.join(pkgRoot, 'DEBIAN');
- await fsp.mkdir(debianDir, { recursive: true });
- await fsp.writeFile(path.join(debianDir, 'postinst'), postinst, { mode: 0o755 });
- await fsp.writeFile(path.join(debianDir, 'control'),
- `Package: betterdesk-support\nVersion: 1.0.0\nArchitecture: amd64\nMaintainer: BetterDesk\nDescription: BetterDesk Support Agent\n`,
- 'utf8');
- const debPath = path.join(packDir, `betterdesk-support-${label}.deb`);
- await _runProcess('dpkg-deb', ['--build', pkgRoot, debPath], { cwd: packDir });
- return debPath;
- }
- case 'rpm': {
- const topdir = path.join(packDir, 'rpmbuild');
- for (const sub of ['BUILD', 'RPMS', 'SOURCES', 'SPECS', 'SRPMS', 'BUILDROOT']) {
- await fsp.mkdir(path.join(topdir, sub), { recursive: true });
- }
- const buildDir = path.join(topdir, 'BUILD');
- const libDir = path.join(buildDir, 'usr', 'lib', 'betterdesk-support');
- const binDir = path.join(buildDir, 'usr', 'local', 'bin');
- await fsp.mkdir(libDir, { recursive: true });
- await fsp.mkdir(binDir, { recursive: true });
- const distDir = path.dirname(binaryPath);
- const layout = await _stageLinuxUI(distDir, libDir, 'betterdesk-support');
- const wrapperPath = path.join(binDir, 'betterdesk-support');
- if (layout === 'dual') {
- await fsp.writeFile(wrapperPath, `#!/bin/sh
-LIB="/usr/lib/betterdesk-support"
-if [ -n "$WAYLAND_DISPLAY" ] && [ -z "$DISPLAY" ] && [ -x "$LIB/betterdesk-support-wayland" ]; then
- exec "$LIB/betterdesk-support-wayland" "$@"
-fi
-exec "$LIB/betterdesk-support-x11" "$@"
-`, { mode: 0o755 });
- } else {
- await fsp.writeFile(wrapperPath, `#!/bin/sh
-exec /usr/lib/betterdesk-support/betterdesk-support "$@"
-`, { mode: 0o755 });
- }
- const filesSection = layout === 'dual'
- ? `/usr/lib/betterdesk-support/betterdesk-support
-/usr/lib/betterdesk-support/betterdesk-support-x11
-/usr/lib/betterdesk-support/betterdesk-support-wayland
-/usr/local/bin/betterdesk-support
-`
- : `/usr/lib/betterdesk-support/betterdesk-support
-/usr/local/bin/betterdesk-support
-`;
- const postInstall = layout === 'dual'
- ? '/usr/lib/betterdesk-support/betterdesk-support-x11 -install || true'
- : '/usr/lib/betterdesk-support/betterdesk-support -install || true';
- const spec = `Name: betterdesk-support
-Version: 1.0.0
-Release: 1%{?dist}
-Summary: BetterDesk Support Agent
-License: AGPL-3.0
-BuildArch: x86_64
-AutoReqProv: no
-
-%description
-BetterDesk branded support agent for end-user workstations.
-
-%install
-mkdir -p %{buildroot}/usr/lib/betterdesk-support
-mkdir -p %{buildroot}/usr/local/bin
-cp -a %{_builddir}/usr/lib/betterdesk-support/. %{buildroot}/usr/lib/betterdesk-support/
-install -m 755 %{_builddir}/usr/local/bin/betterdesk-support %{buildroot}/usr/local/bin/betterdesk-support
-
-%post
-${postInstall}
-
-%files
-${filesSection}`;
- const specPath = path.join(topdir, 'SPECS', 'betterdesk-support.spec');
- await fsp.writeFile(specPath, spec, 'utf8');
- await _runProcess('rpmbuild', [
- '-bb',
- '--define', `_topdir ${topdir}`,
- '--define', `_builddir ${buildDir}`,
- specPath,
- ], { cwd: packDir });
- const rpmsDir = path.join(topdir, 'RPMS', 'x86_64');
- const files = await fsp.readdir(rpmsDir);
- const rpmFile = files.find((f) => f.endsWith('.rpm'));
- if (!rpmFile) throw new Error('rpmbuild produced no .rpm artifact');
- return path.join(rpmsDir, rpmFile);
- }
- case 'appimage': {
- const appDir = path.join(packDir, 'BetterDeskSupport.AppDir');
- const binDir = path.join(appDir, 'usr', 'bin');
- const distDir = path.dirname(binaryPath);
- await fsp.mkdir(binDir, { recursive: true });
- const layout = await _stageLinuxUI(distDir, binDir, 'betterdesk-support');
- await fsp.writeFile(path.join(binDir, 'portable'), '', 'utf8');
-
- const displayName = String(branding.product_name || branding.company_name || 'BetterDesk Support')
- .replace(/[\r\n\t]/g, ' ')
- .trim()
- .slice(0, 80);
-
- const appRun = layout === 'dual'
- ? `#!/bin/sh
-HERE="$(dirname "$(readlink -f "$0")")"
-export PATH="$HERE/usr/bin:$PATH"
-BD_UID="$(id -u 2>/dev/null || echo 0)"
-export XDG_RUNTIME_DIR="\${XDG_RUNTIME_DIR:-/run/user/$BD_UID}"
-if [ -z "\${DBUS_SESSION_BUS_ADDRESS:-}" ] && [ -S "\$XDG_RUNTIME_DIR/bus" ]; then
- export DBUS_SESSION_BUS_ADDRESS="unix:path=\$XDG_RUNTIME_DIR/bus"
-fi
-LAUNCHER="$HERE/usr/bin/betterdesk-support"
-if [ -x "$LAUNCHER" ]; then
- exec "$LAUNCHER" "$@"
-fi
-exec "$HERE/usr/bin/betterdesk-support-x11" "$@"
-`
- : `#!/bin/sh
-HERE="$(dirname "$(readlink -f "$0")")"
-export PATH="$HERE/usr/bin:$PATH"
-BD_UID="$(id -u 2>/dev/null || echo 0)"
-export XDG_RUNTIME_DIR="\${XDG_RUNTIME_DIR:-/run/user/$BD_UID}"
-if [ -z "\${DBUS_SESSION_BUS_ADDRESS:-}" ] && [ -S "\$XDG_RUNTIME_DIR/bus" ]; then
- export DBUS_SESSION_BUS_ADDRESS="unix:path=\$XDG_RUNTIME_DIR/bus"
-fi
-exec "$HERE/usr/bin/betterdesk-support" "$@"
-`;
- await fsp.writeFile(path.join(appDir, 'AppRun'), appRun, { mode: 0o755 });
-
- await fsp.writeFile(path.join(appDir, 'betterdesk-support.desktop'),
- `[Desktop Entry]
-Type=Application
-Name=${displayName}
-Comment=Remote support agent
-Exec=betterdesk-support
-Icon=betterdesk-support
-Categories=Network;Utility;
-Terminal=false
-StartupNotify=true
-`, 'utf8');
-
- await _writeAppImageIcon(appDir, branding);
-
- const outPath = path.join(packDir, `betterdesk-support-${label}-portable.AppImage`);
- const appimagetool = _resolveAppImageTool();
- if (!appimagetool) {
- throw new Error(
- 'appimagetool not found — install via scripts/install-build-toolchain.sh (extracted wrapper)'
- );
- }
- const tmpDir = path.join(BUILD_CACHE_DIR, 'tmp');
- await fsp.mkdir(tmpDir, { recursive: true });
- await _runProcess(appimagetool, ['--no-appstream', appDir, outPath], {
- cwd: packDir,
- env: {
- ARCH: 'x86_64',
- APPIMAGE_EXTRACT_AND_RUN: '1',
- HOME: BUILD_CACHE_DIR,
- TMPDIR: tmpDir,
- TEMP: tmpDir,
- TMP: tmpDir,
- },
- });
- return outPath;
- }
- default:
- throw new Error(`unknown pack profile ${profile.pack}`);
- }
-}
-
-function _runProcess(cmd, args, opts = {}) {
- return new Promise((resolve, reject) => {
- const child = spawn(cmd, args, {
- cwd: opts.cwd,
- env: _buildEnv(opts.env || {}),
- stdio: ['ignore', 'pipe', 'pipe'],
- });
- let stderrTail = '';
- child.stderr.on('data', (chunk) => { stderrTail = (stderrTail + chunk.toString()).slice(-8192); });
- child.stdout.on('data', () => {});
- const timeoutMs = opts.timeoutMs || BUILD_TIMEOUT_MS;
- const timeout = setTimeout(() => {
- try { child.kill('SIGTERM'); } catch (_) { /* ignore */ }
- reject(new Error(`${cmd} timed out after ${timeoutMs}ms`));
- }, timeoutMs);
- child.once('error', (e) => { clearTimeout(timeout); reject(e); });
- child.once('exit', (code) => {
- clearTimeout(timeout);
- if (code === 0) return resolve();
- reject(new Error(`${cmd} exited ${code}\n${stderrTail}`));
- });
- });
-}
-
-// 1×1 PNG fallback when branding logo is missing or unsupported for AppImage.
-const DEFAULT_APPIMAGE_ICON = Buffer.from(
- 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
- 'base64',
-);
-
-async function _writeAppImageIcon(appDir, branding) {
- const iconPath = path.join(appDir, 'betterdesk-support.png');
- const logo = branding?.logo_data_url || '';
- const match = logo.match(/^data:image\/(png|jpe?g);base64,(.+)$/i);
- if (match) {
- await fsp.writeFile(iconPath, Buffer.from(match[2], 'base64'));
- return;
- }
- await fsp.writeFile(iconPath, DEFAULT_APPIMAGE_ICON);
-}
-
-function _sha256OfFile(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')));
- });
-}
-
-module.exports = {
- enqueueBuildsForHash,
- requeueAllBundleBuilds,
- rebuildBundleById,
- requeueFailedToolchainBuilds,
- requeuePlatformBuild,
- markRebuildPending,
- processPendingRebuildOnStartup,
- reconcileAgentSourceDrift,
- stageSourcesFromGitHub,
- syncFullAgentSourceFromGitHub,
- startWorker,
- stopWorker,
- getReadyArtifact,
- getGoBin,
- getBuildWorkerStatus,
- classifyBuildError,
- _internals: {
- BUILD_PROFILES,
- BUILD_CACHE_DIR,
- ARTIFACT_ROOT,
- SOURCE_ROOT,
- isSupportAgentBundle: _isSupportAgentBundle,
- listPendingBuilds: _listPendingBuilds,
- getSupportAgentBuildVersion: _getSupportAgentBuildVersion,
- injectSupportAgentVersion: _injectSupportAgentVersion,
- buildFingerprint: _buildFingerprint,
- assertReleaseSupportProfile: _assertReleaseSupportProfile,
- ensureFreshSupportProfile: _ensureFreshSupportProfile,
- parseBundleBranding: _parseBundleBranding,
- hasDualLinuxUI: _hasDualLinuxUI,
- stageLinuxUI: _stageLinuxUI,
- needsCompile: _needsCompile,
- },
-};
diff --git a/web-nodejs/services/agentBundleService.js b/web-nodejs/services/agentBundleService.js
index 60cdefb3..5a992f30 100644
--- a/web-nodejs/services/agentBundleService.js
+++ b/web-nodejs/services/agentBundleService.js
@@ -22,13 +22,14 @@ const config = require('../config/config');
const conn = require('./agentBundleConnection');
// Supported delivery targets. The portal renders one card per entry.
+// BetterDesk Support Generator patches portable desktop templates + custom.txt.
const PLATFORMS = [
- { platform: 'windows', arch: 'x64', format: 'portable', label: 'Windows portable (.exe)' },
- { platform: 'windows', arch: 'x64', format: 'installed', label: 'Windows installed (.msi)' },
- { platform: 'linux', arch: 'x64', format: 'portable', label: 'Linux universal portable (.tar.gz)' },
- { platform: 'linux', arch: 'x64', format: 'appimage', label: 'Linux portable (AppImage)' },
- { platform: 'linux', arch: 'x64', format: 'installed', label: 'Linux Debian/Ubuntu (.deb)' },
- { platform: 'linux', arch: 'x64', format: 'rpm', label: 'Linux Fedora/RHEL (.rpm)' },
+ { platform: 'windows', arch: 'x64', format: 'portable', label: 'Windows x64 portable (.zip)' },
+ { platform: 'windows', arch: 'arm64', format: 'portable', label: 'Windows ARM64 portable (.zip)' },
+ { platform: 'linux', arch: 'x64', format: 'portable', label: 'Linux x64 portable (.tar.gz)' },
+ { platform: 'linux', arch: 'arm64', format: 'portable', label: 'Linux ARM64 portable (.tar.gz)' },
+ { platform: 'macos', arch: 'x64', format: 'portable', label: 'macOS Intel portable (.tar.gz)' },
+ { platform: 'macos', arch: 'arm64', format: 'portable', label: 'macOS Apple Silicon portable (.tar.gz)' },
];
const SUPPORTED_LANGS = [
@@ -122,11 +123,19 @@ function validateBranding(input = {}) {
const errors = [];
const out = {};
- out.company_name = clip(input.company_name || input.companyName, MAX_NAME);
- // Optional for quick Support Agent creation — defaults to product name.
+ out.app_name = clip(input.app_name || input.appName || input.company_name || input.companyName, MAX_NAME);
+ out.company_name = clip(input.company_name || input.companyName || out.app_name, MAX_NAME);
+ // Optional for quick Support creation — defaults to product name.
if (!out.company_name) {
- out.company_name = 'BetterDesk Support';
+ out.company_name = 'BetterDesk Support Agent';
}
+ if (!out.app_name) {
+ out.app_name = out.company_name;
+ }
+ out.relay_host = clip(input.relay_host || input.relayHost || input.relay_server || '', 253);
+ out.api_server = clip(input.api_server || input.apiServer || '', MAX_CONTACT);
+ out.api_port = clip(String(input.api_port || input.apiPort || ''), 8);
+ out.disable_settings = input.disable_settings !== false && input.disableSettings !== false;
out.short_text = clip(input.short_text || input.shortText, MAX_SHORT_TEXT);
applyPortalProductFields(input, out);
@@ -363,6 +372,7 @@ function publicBundleId(row) {
/** Default branding used as a starting point in the editor. */
function defaultBranding() {
return {
+ app_name: 'BetterDesk Support Agent',
company_name: '',
short_text: '',
product_label: '',
@@ -380,6 +390,7 @@ function defaultBranding() {
status_ready_color: '#22c55e',
header_text_color: '#1f2937',
allow_unattended: false,
+ disable_settings: true,
capabilities: {
desktop: true,
files: true,
@@ -390,7 +401,11 @@ function defaultBranding() {
},
default_lang: 'en',
server_host: '',
+ relay_host: '',
+ api_server: '',
+ api_port: '',
use_https: true,
+ public_key: '',
server: { address: '', api_url: '', public_key: '' },
};
}
diff --git a/web-nodejs/services/agentClientBuildWorker.js b/web-nodejs/services/agentClientBuildWorker.js
index 1b74d931..4d7f0973 100644
--- a/web-nodejs/services/agentClientBuildWorker.js
+++ b/web-nodejs/services/agentClientBuildWorker.js
@@ -47,6 +47,7 @@ const ARTIFACT_ROOT = process.env.AGENT_CLIENT_ARTIFACT_DIR
|| path.join(config.dataDir || '/opt/BetterDeskConsole/data', 'agent-client-builds');
const POLL_INTERVAL_MS = parseInt(process.env.AGENT_CLIENT_BUILD_POLL_MS || '8000', 10);
const BUILD_TIMEOUT_MS = parseInt(process.env.AGENT_CLIENT_BUILD_TIMEOUT_MS || (45 * 60 * 1000), 10);
+const IS_WINDOWS = process.platform === 'win32';
const REPO_ROOT = path.resolve(__dirname, '..', '..');
function _resolveSourceRoot() {
@@ -124,8 +125,9 @@ const SIDECAR_NAMES = {
'windows/x64': 'betterdesk-agent-x86_64-pc-windows-msvc.exe',
};
-function _isAgentClientBundle(bundle) {
- return normalizeProductType(bundle?.product_type) === PRODUCT_TYPES.AGENT_CLIENT;
+function _isAgentClientBundle(_bundle) {
+ // Product types collapsed to betterdesk-support; agent-client worker is unused.
+ return false;
}
let _pollTimer = null;
@@ -208,10 +210,9 @@ async function _ensureAgentLib() {
}
async function _buildSidecar(profile, destPath) {
- const agentBuildWorker = require('./agentBuildWorker');
- const goBin = typeof agentBuildWorker.getGoBin === 'function'
- ? agentBuildWorker.getGoBin()
- : null;
+ // Prefer PATH / common Go locations (legacy agentBuildWorker removed).
+ const goBin = process.env.GO_BIN
+ || (IS_WINDOWS ? 'go.exe' : 'go');
if (!goBin) throw new Error('Go toolchain not available for sidecar build');
const agentLib = await _ensureAgentLib();
diff --git a/web-nodejs/services/bundleSigningKey.js b/web-nodejs/services/bundleSigningKey.js
deleted file mode 100644
index 5321480d..00000000
--- a/web-nodejs/services/bundleSigningKey.js
+++ /dev/null
@@ -1,65 +0,0 @@
-'use strict';
-
-/**
- * Resolves the private Ed25519 key used to sign Support Agent branding
- * profiles. The generated public half is embedded in each signed binary by
- * the Go build helper; the private half never belongs in the bundle or the
- * artifact cache.
- */
-
-const crypto = require('crypto');
-const fs = require('fs');
-const fsp = require('fs/promises');
-const path = require('path');
-
-const DEFAULT_KEY_FILE = 'support-agent-branding-ed25519.pem';
-
-async function validateSigningKeyFile(keyFile) {
- const pem = await fsp.readFile(keyFile);
- const key = crypto.createPrivateKey(pem);
- if (key.asymmetricKeyType !== 'ed25519') {
- throw new Error(`Support Agent branding key must be Ed25519, got ${key.asymmetricKeyType || 'unknown'}`);
- }
-}
-
-async function resolveBundleSigningKeyFile({ keysPath, env = process.env } = {}) {
- const configured = String(env.BETTERDESK_BUNDLE_SIGNING_KEY_FILE || '').trim();
- if (configured) {
- await validateSigningKeyFile(configured);
- return configured;
- }
- if (!keysPath) {
- throw new Error('keysPath is required to create the Support Agent branding signing key');
- }
-
- const keyFile = path.join(keysPath, DEFAULT_KEY_FILE);
- try {
- await validateSigningKeyFile(keyFile);
- return keyFile;
- } catch {
- // Create below. The final link-safe write handles concurrent workers.
- }
-
- await fsp.mkdir(keysPath, { recursive: true, mode: 0o700 });
- const { privateKey } = crypto.generateKeyPairSync('ed25519');
- const pem = privateKey.export({ type: 'pkcs8', format: 'pem' });
- try {
- const handle = await fsp.open(keyFile, 'wx', 0o600);
- try {
- await handle.writeFile(pem);
- } finally {
- await handle.close();
- }
- } catch (err) {
- if (!err || err.code !== 'EEXIST') throw err;
- }
- await fsp.chmod(keyFile, 0o600).catch(() => {});
- await validateSigningKeyFile(keyFile);
- return keyFile;
-}
-
-module.exports = {
- DEFAULT_KEY_FILE,
- resolveBundleSigningKeyFile,
- validateSigningKeyFile,
-};
diff --git a/web-nodejs/services/clientTemplateWorker.js b/web-nodejs/services/clientTemplateWorker.js
new file mode 100644
index 00000000..7085efdd
--- /dev/null
+++ b/web-nodejs/services/clientTemplateWorker.js
@@ -0,0 +1,492 @@
+/**
+ * BetterDesk Support Generator — template worker
+ *
+ * Patches portable desktop templates from the Support Generator module with
+ * a signed/plain custom.txt and packages artifacts under data/agent-builds/.
+ * Replaces agentBuildWorker for product_type=betterdesk-support.
+ */
+
+'use strict';
+
+const fs = require('fs');
+const fsp = fs.promises;
+const path = require('path');
+const crypto = require('crypto');
+const { spawn } = require('child_process');
+
+const AdmZip = require('adm-zip');
+
+const db = require('./database');
+const bundleService = require('./agentBundleService');
+const supportModule = require('./supportGeneratorModule');
+const customTxt = require('./customTxtBuilder');
+const keyService = require('./keyService');
+const conn = require('./agentBundleConnection');
+const config = require('../config/config');
+const {
+ PRODUCT_TYPES,
+ normalizeProductType,
+ isQueuedBuildStatus,
+} = require('../lib/generatorBuildTypes');
+
+const ARTIFACT_ROOT = process.env.AGENT_ARTIFACT_DIR
+ || path.join(config.dataDir || '/opt/BetterDeskConsole/data', 'agent-builds');
+const WORK_ROOT = path.join(
+ config.dataDir || path.join(__dirname, '..', 'data'),
+ 'build-cache',
+ 'support-templates'
+);
+const POLL_INTERVAL_MS = parseInt(process.env.AGENT_BUILD_POLL_MS || '5000', 10);
+const BUILD_COOLDOWN_MS = parseInt(process.env.AGENT_BUILD_COOLDOWN_MS || '1000', 10);
+const IS_WINDOWS = process.platform === 'win32';
+
+let _pollHandle = null;
+let _running = false;
+let _activeBuilds = 0;
+let _lastBuildFinishedAt = 0;
+let _startupReady = false;
+
+function _isSupportProduct(row) {
+ const pt = normalizeProductType(row?.product_type);
+ return pt === PRODUCT_TYPES.BETTERDESK_SUPPORT;
+}
+
+function _parseBranding(raw) {
+ if (!raw) return {};
+ if (typeof raw === 'object') return raw;
+ try { return JSON.parse(raw); } catch (_) { return {}; }
+}
+
+function _filterPlatforms(only) {
+ const all = bundleService.PLATFORMS || [];
+ if (!Array.isArray(only) || only.length === 0) return all;
+ const filtered = all.filter((p) => only.some((o) => (
+ String(o.platform || o.os || '') === p.platform
+ && String(o.arch || 'x64') === p.arch
+ && String(o.format || 'portable') === p.format
+ )));
+ return filtered.length ? filtered : all;
+}
+
+async function enqueueBuildsForHash(brandingHash, { force = false, platforms: onlyPlatforms = null } = {}) {
+ if (!brandingHash) throw new Error('brandingHash required');
+ if (!supportModule.isReady()) {
+ throw new Error('support_generator_module_not_ready');
+ }
+ const platforms = _filterPlatforms(onlyPlatforms);
+ 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: 'queued',
+ artifactPath: existing?.artifact_path || null,
+ artifactSize: existing?.artifact_size || 0,
+ artifactSha256: existing?.artifact_sha256 || null,
+ errorMessage: '',
+ });
+ }
+}
+
+async function rebuildBundleById(bundleId, { platforms: onlyPlatforms = null } = {}) {
+ const row = await db.getAgentBundle(bundleId);
+ if (!row) return { success: false, error: 'not_found' };
+ if (!_isSupportProduct(row)) return { success: false, error: 'not_betterdesk_support' };
+ if (!row.branding_hash) return { success: false, error: 'missing_hash' };
+ const platforms = _filterPlatforms(onlyPlatforms);
+ await enqueueBuildsForHash(row.branding_hash, { force: true, platforms });
+ return { success: true, platforms: platforms.length, brandingHash: row.branding_hash };
+}
+
+async function requeuePlatformBuild(brandingHash, platform, arch, format) {
+ if (!brandingHash || !platform || !arch || !format) {
+ return { success: false, error: 'missing_args' };
+ }
+ const allowed = (bundleService.PLATFORMS || []).some(
+ (p) => p.platform === platform && p.arch === arch && p.format === format
+ );
+ if (!allowed) return { success: false, error: 'unsupported_platform' };
+ await db.upsertAgentBundleBuild({
+ brandingHash,
+ platform,
+ arch,
+ format,
+ status: 'queued',
+ artifactPath: null,
+ artifactSize: 0,
+ artifactSha256: null,
+ errorMessage: '',
+ });
+ return { success: true, brandingHash };
+}
+
+function getBuildWorkerStatus() {
+ const moduleStatus = (() => {
+ try {
+ return {
+ ready: supportModule.isReady(),
+ templatesPresent: supportModule.templatesExist(),
+ };
+ } catch (_) {
+ return { ready: false, templatesPresent: false };
+ }
+ })();
+ return {
+ workerEnabled: process.env.AGENT_BUILD_WORKER !== 'off',
+ kind: 'client-template',
+ moduleReady: moduleStatus.ready,
+ templatesPresent: moduleStatus.templatesPresent,
+ templatesDir: supportModule.templatesDir(),
+ artifactRoot: ARTIFACT_ROOT,
+ platforms: (bundleService.PLATFORMS || []).map((p) => ({
+ platform: p.platform,
+ arch: p.arch,
+ format: p.format,
+ label: p.label,
+ })),
+ activeBuilds: _activeBuilds,
+ startupReady: _startupReady,
+ };
+}
+
+async function getReadyArtifact({ brandingHash, platform, arch, format }) {
+ const row = await db.getAgentBundleBuild({ brandingHash, platform, arch, format });
+ if (!row || row.status !== 'ready' || !row.artifact_path) return null;
+ try {
+ await fsp.access(row.artifact_path, fs.constants.R_OK);
+ } catch {
+ return null;
+ }
+ return row;
+}
+
+function startWorker() {
+ if (_pollHandle) return;
+ console.log(`[clientTemplateWorker] templates=${supportModule.templatesDir()}`);
+ _startupReady = true;
+ _pollHandle = setInterval(() => {
+ _tick().catch((e) => console.error('[clientTemplateWorker] tick error:', e.message));
+ }, POLL_INTERVAL_MS);
+ _tick().catch(() => {});
+ console.log(`[clientTemplateWorker] started (poll ${POLL_INTERVAL_MS}ms)`);
+}
+
+function stopWorker() {
+ if (_pollHandle) {
+ clearInterval(_pollHandle);
+ _pollHandle = null;
+ }
+ _startupReady = false;
+}
+
+async function _tick() {
+ if (!_startupReady) return;
+ if (_running || _activeBuilds > 0) return;
+ if (BUILD_COOLDOWN_MS > 0 && Date.now() - _lastBuildFinishedAt < BUILD_COOLDOWN_MS) return;
+
+ _running = true;
+ try {
+ const claimed = await _claimNextBuild();
+ if (!claimed) return;
+ _activeBuilds++;
+ try {
+ await _runOne(claimed);
+ } catch (e) {
+ console.error('[clientTemplateWorker] build crashed:', e);
+ } finally {
+ _activeBuilds--;
+ _lastBuildFinishedAt = Date.now();
+ }
+ } finally {
+ _running = false;
+ }
+}
+
+async function _findBundleForHash(brandingHash) {
+ const bundles = await db.listAgentBundles({ includeRevoked: true });
+ return (bundles || []).find((b) => b.branding_hash === brandingHash) || null;
+}
+
+async function _listPendingBuilds(limit = 50) {
+ const bundles = await db.listAgentBundles();
+ const out = [];
+ for (const b of bundles || []) {
+ if (b.revoked || !_isSupportProduct(b)) continue;
+ const builds = await db.listAgentBundleBuildsForHash(b.branding_hash);
+ for (const row of builds || []) {
+ if (isQueuedBuildStatus(row.status)) out.push(row);
+ if (out.length >= limit) return out;
+ }
+ }
+ return out;
+}
+
+async function _claimNextBuild() {
+ if (!supportModule.isReady()) return null;
+ const candidates = await _listPendingBuilds(50);
+ candidates.sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')));
+ for (const row of candidates) {
+ const bundleRow = await _findBundleForHash(row.branding_hash);
+ if (!bundleRow || !_isSupportProduct(bundleRow)) continue;
+ if (!supportModule.resolveTemplateDir(row.platform, row.arch)) {
+ 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: `template_missing:${row.platform}/${row.arch}`,
+ });
+ continue;
+ }
+ 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: '',
+ });
+ return { ...row, _bundle: bundleRow };
+ }
+ return null;
+}
+
+function _buildApiServer(branding) {
+ if (branding.api_server) return String(branding.api_server).trim();
+ const host = branding.server_host || conn.defaultServerHost();
+ const useHttps = branding.use_https ?? true;
+ const port = String(branding.api_port || conn.defaultApiPort());
+ const scheme = useHttps ? 'https' : 'http';
+ const omit = (scheme === 'https' && port === '443') || (scheme === 'http' && port === '80');
+ return omit ? `${scheme}://${host}` : `${scheme}://${host}:${port}`;
+}
+
+async function _buildCustomTxtContent(branding) {
+ const host = branding.server_host || conn.defaultServerHost();
+ const key = branding.public_key
+ || branding.server_key
+ || branding.server?.public_key
+ || (await keyService.resolvePublicKey())
+ || '';
+ const built = customTxt.buildAndSignSupportCustomTxt({
+ appName: branding.app_name || branding.company_name || branding.product_name || 'BetterDesk Support Agent',
+ host,
+ relay: branding.relay_host || branding.relay_server || host,
+ api: _buildApiServer(branding),
+ key,
+ disableSettings: branding.disable_settings !== false,
+ }, supportModule.getSigningSeedBase64());
+ return built;
+}
+
+async function _copyDir(src, dest) {
+ await fsp.cp(src, dest, { recursive: true });
+}
+
+function _findCustomTxtTarget(stageDir, platform) {
+ if (String(platform).toLowerCase() === 'macos') {
+ const marker = _findFile(stageDir, '.custom-txt-here');
+ if (marker) return path.dirname(marker);
+ const app = _findDirEnding(stageDir, '.app');
+ if (app) {
+ const macos = path.join(app, 'Contents', 'MacOS');
+ if (fs.existsSync(macos)) return macos;
+ }
+ const contentsMac = _findDirNamed(stageDir, 'MacOS');
+ if (contentsMac) return contentsMac;
+ }
+ const marker = path.join(stageDir, '.custom-txt-here');
+ if (fs.existsSync(marker)) return stageDir;
+ return stageDir;
+}
+
+function _findFile(root, name) {
+ const stack = [root];
+ while (stack.length) {
+ const dir = stack.pop();
+ let entries;
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { continue; }
+ for (const e of entries) {
+ const full = path.join(dir, e.name);
+ if (e.isDirectory()) stack.push(full);
+ else if (e.name === name) return full;
+ }
+ }
+ return null;
+}
+
+function _findDirEnding(root, suffix) {
+ const stack = [root];
+ while (stack.length) {
+ const dir = stack.pop();
+ let entries;
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { continue; }
+ for (const e of entries) {
+ if (!e.isDirectory()) continue;
+ const full = path.join(dir, e.name);
+ if (e.name.endsWith(suffix)) return full;
+ stack.push(full);
+ }
+ }
+ return null;
+}
+
+function _findDirNamed(root, name) {
+ const stack = [root];
+ while (stack.length) {
+ const dir = stack.pop();
+ let entries;
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { continue; }
+ for (const e of entries) {
+ if (!e.isDirectory()) continue;
+ const full = path.join(dir, e.name);
+ if (e.name === name) return full;
+ stack.push(full);
+ }
+ }
+ return null;
+}
+
+function _runTar(args, cwd) {
+ return new Promise((resolve, reject) => {
+ const child = spawn('tar', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
+ let stderr = '';
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
+ child.on('error', reject);
+ child.on('close', (code) => {
+ if (code === 0) resolve();
+ else reject(new Error(`tar failed (${code}): ${stderr.trim() || 'unknown'}`));
+ });
+ });
+}
+
+async function _packArtifact(stageDir, outPath, platform) {
+ await fsp.mkdir(path.dirname(outPath), { recursive: true });
+ if (fs.existsSync(outPath)) await fsp.unlink(outPath);
+
+ if (String(platform).toLowerCase() === 'windows' || outPath.endsWith('.zip')) {
+ const zip = new AdmZip();
+ zip.addLocalFolder(stageDir, path.basename(stageDir));
+ zip.writeZip(outPath);
+ return;
+ }
+
+ // tar.gz — archive the stage directory contents under a single top-level folder
+ const parent = path.dirname(stageDir);
+ const base = path.basename(stageDir);
+ await _runTar(['-czf', outPath, base], parent);
+}
+
+function _sha256OfFile(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 startTs = Date.now();
+ console.log(`[clientTemplateWorker] build start hash=${String(buildRow.branding_hash).slice(0, 12)} ${key}`);
+
+ const workDir = path.join(WORK_ROOT, `${buildRow.branding_hash.slice(0, 16)}_${buildRow.platform}_${buildRow.arch}`);
+ try {
+ await fsp.rm(workDir, { recursive: true, force: true });
+ await fsp.mkdir(workDir, { recursive: true });
+ await fsp.mkdir(ARTIFACT_ROOT, { recursive: true });
+
+ const templateDir = supportModule.resolveTemplateDir(buildRow.platform, buildRow.arch);
+ if (!templateDir) throw new Error(`template_missing:${key}`);
+
+ const stageDir = path.join(workDir, `betterdesk-support-${buildRow.platform}-${buildRow.arch}`);
+ await _copyDir(templateDir, stageDir);
+
+ const branding = _parseBranding(buildRow._bundle?.branding);
+ const { content, signed } = await _buildCustomTxtContent(branding);
+ const injectDir = _findCustomTxtTarget(stageDir, buildRow.platform);
+ await fsp.mkdir(injectDir, { recursive: true });
+ await fsp.writeFile(path.join(injectDir, 'custom.txt'), content, 'utf8');
+
+ // Drop helper markers from shipped artifacts
+ try { await fsp.unlink(path.join(stageDir, '.custom-txt-here')); } catch (_) { /* ok */ }
+ const nestedMarker = _findFile(stageDir, '.custom-txt-here');
+ if (nestedMarker) {
+ try { await fsp.unlink(nestedMarker); } catch (_) { /* ok */ }
+ }
+
+ const ext = buildRow.platform === 'windows' ? 'zip' : 'tar.gz';
+ const artifactName = `betterdesk-support-${buildRow.branding_hash.slice(0, 12)}-${buildRow.platform}-${buildRow.arch}.${ext}`;
+ const artifactPath = path.join(ARTIFACT_ROOT, artifactName);
+ await _packArtifact(stageDir, artifactPath, buildRow.platform);
+
+ const stat = await fsp.stat(artifactPath);
+ const sha = await _sha256OfFile(artifactPath);
+ await db.upsertAgentBundleBuild({
+ brandingHash: buildRow.branding_hash,
+ platform: buildRow.platform,
+ arch: buildRow.arch,
+ format: buildRow.format,
+ status: 'ready',
+ artifactPath,
+ artifactSize: stat.size,
+ artifactSha256: sha,
+ errorMessage: '',
+ });
+ console.log(
+ `[clientTemplateWorker] build ready ${key} signed=${signed}`
+ + ` (${(stat.size / 1024 / 1024).toFixed(2)} MB, ${((Date.now() - startTs) / 1000).toFixed(1)}s)`
+ );
+ } catch (err) {
+ const msg = err.message || String(err);
+ console.error(`[clientTemplateWorker] build FAILED ${key}: ${msg}`);
+ await db.upsertAgentBundleBuild({
+ brandingHash: buildRow.branding_hash,
+ platform: buildRow.platform,
+ arch: buildRow.arch,
+ format: buildRow.format,
+ status: 'failed',
+ artifactPath: null,
+ artifactSize: 0,
+ artifactSha256: null,
+ errorMessage: msg.slice(0, 2000),
+ });
+ } finally {
+ try { await fsp.rm(workDir, { recursive: true, force: true }); } catch (_) { /* ok */ }
+ }
+}
+
+module.exports = {
+ enqueueBuildsForHash,
+ rebuildBundleById,
+ requeuePlatformBuild,
+ startWorker,
+ stopWorker,
+ getBuildWorkerStatus,
+ getReadyArtifact,
+ _internals: {
+ isSupportProduct: _isSupportProduct,
+ buildCustomTxtContent: _buildCustomTxtContent,
+ findCustomTxtTarget: _findCustomTxtTarget,
+ filterPlatforms: _filterPlatforms,
+ ARTIFACT_ROOT,
+ WORK_ROOT,
+ IS_WINDOWS,
+ },
+};
diff --git a/web-nodejs/services/customTxtBuilder.js b/web-nodejs/services/customTxtBuilder.js
new file mode 100644
index 00000000..ba959f91
--- /dev/null
+++ b/web-nodejs/services/customTxtBuilder.js
@@ -0,0 +1,130 @@
+/**
+ * Build and optionally sign BetterDesk Support `custom.txt` payloads.
+ *
+ * Phase A: plain JSON (file starts with `{`)
+ * Phase B: base64(NaCl-sign(JSON bytes)) when a signing seed is available
+ */
+
+'use strict';
+
+const nacl = require('tweetnacl');
+
+/**
+ * @param {{
+ * appName?: string,
+ * host: string,
+ * relay?: string,
+ * api: string,
+ * key: string,
+ * disableSettings?: boolean|string,
+ * }} opts
+ * @returns {object}
+ */
+function buildSupportCustomTxt({
+ appName,
+ host,
+ relay,
+ api,
+ key,
+ disableSettings = true,
+} = {}) {
+ const rendezvous = String(host || '').trim();
+ const relayHost = String(relay || host || '').trim();
+ const apiServer = String(api || '').trim();
+ const pubKey = String(key || '').trim();
+ const disable = disableSettings === true || disableSettings === 'Y' || disableSettings === 'y'
+ ? 'Y'
+ : 'N';
+
+ return {
+ 'app-name': String(appName || 'BetterDesk Support Agent').trim() || 'BetterDesk Support Agent',
+ 'conn-type': 'incoming',
+ 'disable-settings': disable,
+ 'override-settings': {
+ 'custom-rendezvous-server': rendezvous,
+ 'relay-server': relayHost,
+ 'api-server': apiServer,
+ key: pubKey,
+ 'hide-server-settings': 'Y',
+ 'hide-help-cards': 'Y',
+ },
+ };
+}
+
+/**
+ * Stable JSON bytes for signing (sorted keys, compact).
+ * @param {object} json
+ * @returns {Buffer}
+ */
+function stableJsonBytes(json) {
+ return Buffer.from(JSON.stringify(sortKeys(json)), 'utf8');
+}
+
+function sortKeys(value) {
+ if (value === null || typeof value !== 'object') return value;
+ if (Array.isArray(value)) return value.map(sortKeys);
+ const out = {};
+ for (const k of Object.keys(value).sort()) {
+ out[k] = sortKeys(value[k]);
+ }
+ return out;
+}
+
+/**
+ * Sign custom-client JSON with a 32-byte NaCl seed (base64).
+ * Output matches BetterDesk-Client `sign_custom_client_config.py`:
+ * base64(signature || message).
+ *
+ * @param {object} json
+ * @param {string} seedBase64
+ * @returns {{ content: string, signed: boolean }}
+ */
+function signCustomTxt(json, seedBase64) {
+ const seedText = String(seedBase64 || '').trim();
+ const message = Buffer.isBuffer(json) ? json : stableJsonBytes(json);
+
+ if (!seedText) {
+ return {
+ content: message.toString('utf8'),
+ signed: false,
+ };
+ }
+
+ let seed;
+ try {
+ seed = Buffer.from(seedText, 'base64');
+ } catch (_) {
+ return { content: message.toString('utf8'), signed: false };
+ }
+ if (seed.length !== 32) {
+ return { content: message.toString('utf8'), signed: false };
+ }
+
+ try {
+ const keyPair = nacl.sign.keyPair.fromSeed(seed);
+ const signed = nacl.sign(new Uint8Array(message), keyPair.secretKey);
+ return {
+ content: Buffer.from(signed).toString('base64'),
+ signed: true,
+ };
+ } catch (_) {
+ return { content: message.toString('utf8'), signed: false };
+ }
+}
+
+/**
+ * Build Support Agent custom.txt file contents (plain or signed).
+ * @returns {{ content: string, signed: boolean, json: object }}
+ */
+function buildAndSignSupportCustomTxt(opts, seedBase64) {
+ const json = buildSupportCustomTxt(opts);
+ const result = signCustomTxt(json, seedBase64);
+ return { ...result, json };
+}
+
+module.exports = {
+ buildSupportCustomTxt,
+ signCustomTxt,
+ buildAndSignSupportCustomTxt,
+ stableJsonBytes,
+};
diff --git a/web-nodejs/services/dbAdapter.js b/web-nodejs/services/dbAdapter.js
index 95a814c5..a244cbb2 100644
--- a/web-nodejs/services/dbAdapter.js
+++ b/web-nodejs/services/dbAdapter.js
@@ -993,7 +993,7 @@ function createSqliteAdapter(config) {
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 'support-agent'");
+ db.exec("ALTER TABLE agent_bundles ADD COLUMN product_type TEXT NOT NULL DEFAULT 'betterdesk-support'");
console.log('[DB] Migration: added agent_bundles.product_type');
}
// SQLite cannot alter a column default in place. Normalize legacy
@@ -1001,13 +1001,18 @@ function createSqliteAdapter(config) {
db.exec(`
UPDATE agent_bundles
SET product_type = CASE LOWER(TRIM(COALESCE(product_type, '')))
- WHEN 'rdclient' THEN 'rdclient'
- WHEN 'agent-client' THEN 'agent-client'
- WHEN 'agent_client' THEN 'agent-client'
- ELSE 'support-agent'
+ WHEN 'betterdesk-support' THEN 'betterdesk-support'
+ WHEN 'betterdesk_support' THEN 'betterdesk-support'
+ WHEN 'rdclient' THEN 'betterdesk-support'
+ WHEN 'agent-client' THEN 'betterdesk-support'
+ WHEN 'agent_client' THEN 'betterdesk-support'
+ WHEN 'support-agent' THEN 'betterdesk-support'
+ WHEN 'support_agent' THEN 'betterdesk-support'
+ WHEN 'agent' THEN 'betterdesk-support'
+ ELSE 'betterdesk-support'
END
WHERE product_type IS NULL
- OR LOWER(TRIM(product_type)) NOT IN ('support-agent', 'agent-client', 'rdclient')
+ OR LOWER(TRIM(product_type)) NOT IN ('betterdesk-support')
`);
} catch (e) {
console.warn('[DB] Migration agent_bundles.product_type error:', e.message);
@@ -1024,7 +1029,7 @@ function createSqliteAdapter(config) {
branding TEXT NOT NULL DEFAULT '{}',
branding_hash TEXT NOT NULL DEFAULT '',
created_by INTEGER DEFAULT NULL,
- product_type TEXT NOT NULL DEFAULT 'support-agent',
+ product_type TEXT NOT NULL DEFAULT 'betterdesk-support',
revoked INTEGER NOT NULL DEFAULT 0,
download_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
@@ -4230,7 +4235,7 @@ function createPostgresAdapter() {
branding TEXT NOT NULL DEFAULT '{}',
branding_hash TEXT NOT NULL DEFAULT '',
created_by INTEGER DEFAULT NULL,
- product_type TEXT NOT NULL DEFAULT 'support-agent',
+ product_type TEXT NOT NULL DEFAULT 'betterdesk-support',
revoked BOOLEAN NOT NULL DEFAULT FALSE,
download_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -4268,25 +4273,30 @@ function createPostgresAdapter() {
WHERE table_name = 'agent_bundles' AND column_name = 'product_type'`
);
if (productTypeCols.length === 0) {
- await q("ALTER TABLE agent_bundles ADD COLUMN product_type TEXT NOT NULL DEFAULT 'support-agent'");
+ await q("ALTER TABLE agent_bundles ADD COLUMN product_type TEXT NOT NULL DEFAULT 'betterdesk-support'");
console.log('[DB] Migration: added agent_bundles.product_type');
} else {
await q(`
UPDATE agent_bundles
SET product_type = CASE LOWER(TRIM(COALESCE(product_type, '')))
- WHEN 'rdclient' THEN 'rdclient'
- WHEN 'agent-client' THEN 'agent-client'
- WHEN 'agent_client' THEN 'agent-client'
- ELSE 'support-agent'
+ WHEN 'betterdesk-support' THEN 'betterdesk-support'
+ WHEN 'betterdesk_support' THEN 'betterdesk-support'
+ WHEN 'rdclient' THEN 'betterdesk-support'
+ WHEN 'agent-client' THEN 'betterdesk-support'
+ WHEN 'agent_client' THEN 'betterdesk-support'
+ WHEN 'support-agent' THEN 'betterdesk-support'
+ WHEN 'support_agent' THEN 'betterdesk-support'
+ WHEN 'agent' THEN 'betterdesk-support'
+ ELSE 'betterdesk-support'
END
WHERE product_type IS NULL
- OR LOWER(TRIM(product_type)) NOT IN ('support-agent', 'agent-client', 'rdclient')
+ OR LOWER(TRIM(product_type)) NOT IN ('betterdesk-support')
`);
if (productTypeCols[0].is_nullable === 'YES') {
await q('ALTER TABLE agent_bundles ALTER COLUMN product_type SET NOT NULL');
}
- if (!String(productTypeCols[0].column_default || '').includes('support-agent')) {
- await q("ALTER TABLE agent_bundles ALTER COLUMN product_type SET DEFAULT 'support-agent'");
+ if (!String(productTypeCols[0].column_default || '').includes('betterdesk-support')) {
+ await q("ALTER TABLE agent_bundles ALTER COLUMN product_type SET DEFAULT 'betterdesk-support'");
}
}
} catch (e) {
diff --git a/web-nodejs/services/rdclientBuildWorker.js b/web-nodejs/services/rdclientBuildWorker.js
index 39336025..94342d32 100644
--- a/web-nodejs/services/rdclientBuildWorker.js
+++ b/web-nodejs/services/rdclientBuildWorker.js
@@ -55,8 +55,9 @@ const BUILD_PROFILES = {
'linux/x64/rpm': { os: 'linux', bundles: ['rpm'], artifact: 'rpm' },
};
-function _isRdclientBundle(bundle) {
- return normalizeProductType(bundle?.product_type) === PRODUCT_TYPES.RDCLIENT;
+function _isRdclientBundle(_bundle) {
+ // Product types collapsed to betterdesk-support; rdclient worker is unused.
+ return false;
}
let _pollTimer = null;
diff --git a/web-nodejs/services/supportAgentProfile.js b/web-nodejs/services/supportAgentProfile.js
deleted file mode 100644
index e2bb7ff2..00000000
--- a/web-nodejs/services/supportAgentProfile.js
+++ /dev/null
@@ -1,117 +0,0 @@
-/**
- * Signed Support Agent release profile helpers.
- * Used by the Generator save path and by rebuild/requeue so incomplete or
- * expired profiles can be re-issued without a manual UI save.
- */
-
-'use strict';
-
-const keyService = require('./keyService');
-const conn = require('./agentBundleConnection');
-
-const CERT_PIN_RE = /^[a-f0-9]{64}$/;
-const PROFILE_ERROR =
- 'Support Agent bundle profile is incomplete or expired; save the bundle again to issue a signed profile';
-
-function finalizeBundleBrandingSync(input) {
- const branding = { ...(input || {}) };
- const host = branding.server_host || conn.defaultServerHost();
- const useHttps = branding.use_https ?? true;
- const urls = conn.buildServerUrls(host, useHttps);
- branding.server = {
- address: urls.address,
- api_url: urls.api_url,
- public_key: keyService.getPublicKey() || '',
- cdap_port: urls.cdap_port,
- cdap_url: urls.cdap_url,
- };
- branding.server_address = branding.server.address;
- branding.server_key = branding.server.public_key;
- branding.use_https = !!useHttps;
- return branding;
-}
-
-/**
- * Merge operator connection settings and inject server key.
- * Support-agent bundles do NOT embed a shared enrollment token — each
- * installation registers on its own and receives a unique device_token
- * after operator approval (managed enrollment).
- */
-async function refreshSupportAgentBranding(input) {
- const src = input || {};
- const branding = finalizeBundleBrandingSync(src);
- const pubKey = (await keyService.resolvePublicKey()) || '';
- if (branding.server) {
- branding.server.public_key = pubKey;
- }
- branding.server_key = pubKey;
- delete branding.enrollment_token;
- delete branding.has_enrollment_token;
- delete branding.enrollment_token_masked;
- branding.server_host = src.server_host || conn.defaultServerHost();
- branding.use_https = !!(src.use_https ?? true);
- return branding;
-}
-
-function addSupportProfileValidity(branding, now = new Date()) {
- const ttlDaysRaw = Number.parseInt(process.env.BETTERDESK_AGENT_PROFILE_TTL_DAYS || '365', 10);
- const ttlDays = Number.isFinite(ttlDaysRaw)
- ? Math.max(1, Math.min(ttlDaysRaw, 730))
- : 365;
- const expiresAt = new Date(now.getTime() + ttlDays * 24 * 60 * 60 * 1000);
- const endpoints = [
- branding.server?.address,
- branding.server?.api_url,
- branding.server?.cdap_url,
- ].filter((endpoint, index, all) => {
- if (typeof endpoint !== 'string' || !endpoint) return false;
- // Allow HTTPS/WSS or HTTP/WS (LAN / RustDesk-style plaintext transport).
- if (!/^https?:\/\//i.test(endpoint) && !/^wss?:\/\//i.test(endpoint)) return false;
- return all.indexOf(endpoint) === index;
- });
- branding.profile_issued_at = now.toISOString();
- branding.profile_expires_at = expiresAt.toISOString();
- branding.allowed_endpoints = endpoints;
- return branding;
-}
-
-function assertReleaseSupportProfile(branding) {
- const issuedAt = Date.parse(String(branding?.profile_issued_at || ''));
- const expiresAt = Date.parse(String(branding?.profile_expires_at || ''));
- const endpoints = Array.isArray(branding?.allowed_endpoints) ? branding.allowed_endpoints : [];
- const certPin = String(branding?.server?.cert_pin || '').replace(/:/g, '').trim().toLowerCase();
- const required = [
- branding?.bundle_id,
- branding?.server?.address,
- branding?.server?.api_url,
- branding?.server?.cdap_url,
- ];
- if (required.some((value) => !String(value || '').trim())
- || !Number.isFinite(issuedAt)
- || !Number.isFinite(expiresAt)
- || expiresAt <= Math.max(issuedAt, Date.now())
- || endpoints.length < 3
- || endpoints.some((endpoint) => !/^https?:\/\//i.test(endpoint) && !/^wss?:\/\//i.test(endpoint))
- || (certPin && !CERT_PIN_RE.test(certPin))) {
- throw new Error(PROFILE_ERROR);
- }
-}
-
-function isReleaseSupportProfileValid(branding) {
- try {
- assertReleaseSupportProfile(branding);
- return true;
- } catch (_) {
- return false;
- }
-}
-
-module.exports = {
- CERT_PIN_RE,
- PROFILE_ERROR,
- finalizeBundleBrandingSync,
- refreshSupportAgentBranding,
- addSupportProfileValidity,
- assertReleaseSupportProfile,
- isReleaseSupportProfileValid,
-};
diff --git a/web-nodejs/services/supportGeneratorModule.js b/web-nodejs/services/supportGeneratorModule.js
new file mode 100644
index 00000000..88909e73
--- /dev/null
+++ b/web-nodejs/services/supportGeneratorModule.js
@@ -0,0 +1,350 @@
+/**
+ * BetterDesk Support Generator module install gate.
+ *
+ * Downloads portable desktop templates from BetterDesk-Client GitHub Releases
+ * into `{dataDir}/modules/betterdesk-support-generator/` and tracks install state.
+ */
+
+'use strict';
+
+const fs = require('fs');
+const fsp = fs.promises;
+const path = require('path');
+const { spawn } = require('child_process');
+const https = require('https');
+const http = require('http');
+
+const config = require('../config/config');
+
+const MODULE_ID = 'betterdesk-support-generator';
+const DEFAULT_CLIENT_REPO = 'UNITRONIX/BetterDesk-Client';
+const STATE_STATUSES = new Set(['not_installed', 'downloading', 'ready', 'error']);
+const SIGNING_SEED_NAME = 'custom-client-signing.seed';
+
+function moduleDir() {
+ return path.join(config.dataDir || path.join(__dirname, '..', 'data'), 'modules', MODULE_ID);
+}
+
+function statePath() {
+ return path.join(moduleDir(), 'state.json');
+}
+
+function templatesDir() {
+ return path.join(moduleDir(), 'templates');
+}
+
+function signingSeedPath() {
+ return path.join(moduleDir(), SIGNING_SEED_NAME);
+}
+
+function clientRepo() {
+ return String(process.env.BETTERDESK_CLIENT_REPO || DEFAULT_CLIENT_REPO).trim()
+ || DEFAULT_CLIENT_REPO;
+}
+
+function defaultState() {
+ return {
+ termsAccepted: false,
+ installedVersion: null,
+ status: 'not_installed',
+ error: null,
+ installedAt: null,
+ };
+}
+
+async function ensureModuleDir() {
+ await fsp.mkdir(moduleDir(), { recursive: true });
+}
+
+async function readState() {
+ await ensureModuleDir();
+ try {
+ const raw = await fsp.readFile(statePath(), 'utf8');
+ const parsed = JSON.parse(raw);
+ const base = defaultState();
+ return {
+ ...base,
+ ...parsed,
+ status: STATE_STATUSES.has(parsed.status) ? parsed.status : base.status,
+ termsAccepted: !!parsed.termsAccepted,
+ };
+ } catch (_) {
+ return defaultState();
+ }
+}
+
+async function writeState(patch) {
+ await ensureModuleDir();
+ const current = await readState();
+ const next = {
+ ...current,
+ ...patch,
+ };
+ if (!STATE_STATUSES.has(next.status)) next.status = current.status;
+ await fsp.writeFile(statePath(), JSON.stringify(next, null, 2) + '\n', 'utf8');
+ return next;
+}
+
+function templatesExist() {
+ const root = templatesDir();
+ if (!fs.existsSync(root)) return false;
+ if (fs.existsSync(path.join(root, 'manifest.json'))) return true;
+ try {
+ const entries = fs.readdirSync(root, { withFileTypes: true });
+ return entries.some((e) => e.isDirectory() && /^(windows|linux|macos)-/.test(e.name));
+ } catch (_) {
+ return false;
+ }
+}
+
+function isReady(state) {
+ const s = state || (fs.existsSync(statePath())
+ ? JSON.parse(fs.readFileSync(statePath(), 'utf8'))
+ : defaultState());
+ return !!(s.termsAccepted && s.status === 'ready' && templatesExist());
+}
+
+async function getStatus() {
+ const state = await readState();
+ return {
+ ...state,
+ moduleDir: moduleDir(),
+ templatesDir: templatesDir(),
+ templatesPresent: templatesExist(),
+ signingSeedPresent: fs.existsSync(signingSeedPath()),
+ ready: isReady(state),
+ clientRepo: clientRepo(),
+ };
+}
+
+async function acceptTerms() {
+ return writeState({ termsAccepted: true, error: null });
+}
+
+function _httpGetBuffer(url, redirects = 0) {
+ return new Promise((resolve, reject) => {
+ if (redirects > 8) {
+ reject(new Error('too many redirects'));
+ return;
+ }
+ const lib = String(url).startsWith('https:') ? https : http;
+ const req = lib.get(url, {
+ headers: {
+ 'User-Agent': 'BetterDesk-Console-Generator',
+ Accept: 'application/octet-stream, application/json',
+ },
+ timeout: 120000,
+ }, (res) => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ res.resume();
+ _httpGetBuffer(res.headers.location, redirects + 1).then(resolve, reject);
+ return;
+ }
+ if (res.statusCode !== 200) {
+ res.resume();
+ reject(new Error(`HTTP ${res.statusCode} for ${url}`));
+ return;
+ }
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => resolve(Buffer.concat(chunks)));
+ res.on('error', reject);
+ });
+ req.on('error', reject);
+ req.on('timeout', () => {
+ req.destroy();
+ reject(new Error('download timeout'));
+ });
+ });
+}
+
+async function _httpGetJson(url) {
+ const buf = await _httpGetBuffer(url);
+ return JSON.parse(buf.toString('utf8'));
+}
+
+function _runTar(args, cwd) {
+ return new Promise((resolve, reject) => {
+ const child = spawn('tar', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
+ let stderr = '';
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
+ child.on('error', reject);
+ child.on('close', (code) => {
+ if (code === 0) resolve();
+ else reject(new Error(`tar failed (${code}): ${stderr.trim() || 'unknown'}`));
+ });
+ });
+}
+
+async function _extractTarGz(archivePath, destDir) {
+ await fsp.mkdir(destDir, { recursive: true });
+ await _runTar(['-xzf', archivePath, '-C', destDir]);
+}
+
+async function _rimraf(target) {
+ await fsp.rm(target, { recursive: true, force: true });
+}
+
+/**
+ * Copy signing seed from env or a known on-disk seed into the module dir.
+ */
+async function copySigningSeedIfPresent() {
+ await ensureModuleDir();
+ const fromEnv = String(process.env.BETTERDESK_CUSTOM_CLIENT_SIGNING_SEED || '').trim();
+ if (fromEnv) {
+ await fsp.writeFile(signingSeedPath(), fromEnv.includes('\n') ? fromEnv : `${fromEnv}\n`, 'utf8');
+ return true;
+ }
+ const candidates = [
+ path.join(config.dataDir || path.join(__dirname, '..', 'data'), SIGNING_SEED_NAME),
+ path.join(__dirname, '..', SIGNING_SEED_NAME),
+ path.join(__dirname, '..', '..', 'res', 'betterdesk', SIGNING_SEED_NAME),
+ path.join(process.cwd(), SIGNING_SEED_NAME),
+ ];
+ for (const src of candidates) {
+ if (!fs.existsSync(src)) continue;
+ await fsp.copyFile(src, signingSeedPath());
+ return true;
+ }
+ return false;
+}
+
+function _pickTemplateAsset(assets) {
+ const list = Array.isArray(assets) ? assets : [];
+ const preferred = list.find((a) => /^generator-templates-.*\.tar\.gz$/i.test(a.name || ''));
+ if (preferred) return preferred;
+ return list.find((a) => /generator-templates/i.test(a.name || '') && /\.tar\.gz$/i.test(a.name || ''))
+ || null;
+}
+
+/**
+ * Download generator templates from BetterDesk-Client releases and extract them.
+ * @param {{ repo?: string, tag?: string }} [opts]
+ */
+async function installFromGitHub({ repo, tag } = {}) {
+ const state = await readState();
+ if (!state.termsAccepted) {
+ const err = new Error('terms_not_accepted');
+ err.code = 'terms_not_accepted';
+ throw err;
+ }
+
+ await writeState({ status: 'downloading', error: null });
+
+ try {
+ const targetRepo = String(repo || clientRepo()).trim() || clientRepo();
+ const releaseTag = String(tag || '').trim();
+ const apiBase = `https://api.github.com/repos/${targetRepo}/releases`;
+ const releaseUrl = releaseTag
+ ? `${apiBase}/tags/${encodeURIComponent(releaseTag)}`
+ : `${apiBase}/latest`;
+
+ const release = await _httpGetJson(releaseUrl);
+ const asset = _pickTemplateAsset(release.assets || []);
+ if (!asset || !asset.browser_download_url) {
+ throw new Error(
+ `No generator-templates-*.tar.gz asset found on ${targetRepo}`
+ + (releaseTag ? ` tag ${releaseTag}` : ' latest release')
+ );
+ }
+
+ const tmpDir = path.join(moduleDir(), '.tmp-install');
+ await _rimraf(tmpDir);
+ await fsp.mkdir(tmpDir, { recursive: true });
+ const archivePath = path.join(tmpDir, asset.name || 'generator-templates.tar.gz');
+ const body = await _httpGetBuffer(asset.browser_download_url);
+ await fsp.writeFile(archivePath, body);
+
+ const extractRoot = path.join(tmpDir, 'extract');
+ await _extractTarGz(archivePath, extractRoot);
+
+ // Archive arcname is usually "generator-templates/" — accept either layout.
+ let sourceTemplates = path.join(extractRoot, 'generator-templates');
+ if (!fs.existsSync(sourceTemplates)) {
+ const kids = await fsp.readdir(extractRoot, { withFileTypes: true });
+ const dir = kids.find((k) => k.isDirectory());
+ sourceTemplates = dir ? path.join(extractRoot, dir.name) : extractRoot;
+ }
+
+ const dest = templatesDir();
+ await _rimraf(dest);
+ await fsp.mkdir(path.dirname(dest), { recursive: true });
+ await fsp.rename(sourceTemplates, dest).catch(async () => {
+ // Cross-device rename fallback
+ await fsp.cp(sourceTemplates, dest, { recursive: true });
+ });
+
+ await copySigningSeedIfPresent();
+ await _rimraf(tmpDir);
+
+ const version = String(release.tag_name || release.name || releaseTag || 'unknown').replace(/^v/, '');
+ return writeState({
+ status: 'ready',
+ error: null,
+ installedVersion: version,
+ installedAt: new Date().toISOString(),
+ });
+ } catch (err) {
+ await writeState({
+ status: 'error',
+ error: err.message || String(err),
+ });
+ throw err;
+ }
+}
+
+function resolveTemplateDir(platform, arch) {
+ const archMap = {
+ x64: 'x86_64',
+ amd64: 'x86_64',
+ x86_64: 'x86_64',
+ arm64: 'aarch64',
+ aarch64: 'aarch64',
+ };
+ const p = String(platform || '').toLowerCase();
+ const a = archMap[String(arch || '').toLowerCase()] || String(arch || '');
+ const name = `${p}-${a}`;
+ const candidates = [
+ path.join(templatesDir(), name),
+ path.join(templatesDir(), 'generator-templates', name),
+ ];
+ for (const c of candidates) {
+ if (fs.existsSync(c)) return c;
+ }
+ return null;
+}
+
+function readManifest() {
+ const p = path.join(templatesDir(), 'manifest.json');
+ if (!fs.existsSync(p)) {
+ const alt = path.join(templatesDir(), 'generator-templates', 'manifest.json');
+ if (!fs.existsSync(alt)) return null;
+ try { return JSON.parse(fs.readFileSync(alt, 'utf8')); } catch (_) { return null; }
+ }
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return null; }
+}
+
+function getSigningSeedBase64() {
+ if (fs.existsSync(signingSeedPath())) {
+ return fs.readFileSync(signingSeedPath(), 'utf8').trim();
+ }
+ const fromEnv = String(process.env.BETTERDESK_CUSTOM_CLIENT_SIGNING_SEED || '').trim();
+ return fromEnv || '';
+}
+
+module.exports = {
+ MODULE_ID,
+ moduleDir,
+ templatesDir,
+ signingSeedPath,
+ getStatus,
+ acceptTerms,
+ installFromGitHub,
+ isReady,
+ templatesExist,
+ resolveTemplateDir,
+ readManifest,
+ getSigningSeedBase64,
+ copySigningSeedIfPresent,
+ clientRepo,
+};
diff --git a/web-nodejs/services/updateService.js b/web-nodejs/services/updateService.js
index 66a1c5ab..89f96d9d 100644
--- a/web-nodejs/services/updateService.js
+++ b/web-nodejs/services/updateService.js
@@ -175,13 +175,6 @@ const COMPONENTS = {
service: IS_WINDOWS ? 'BetterDeskAgent' : 'betterdesk-agent',
autoUpdate: false
},
- supportAgent: {
- prefix: 'betterdesk-support-agent/',
- label: 'Support Agent (Generator)',
- localRoot: null,
- service: null,
- autoUpdate: false
- },
scripts: {
// matched by exact file names, not prefix
files: [
@@ -266,22 +259,9 @@ const EXCLUDE_PATTERNS = [
/(^|\/)\.env(\.|$)/ // .env, .env.local, etc.
];
-/** Paths in a commit diff that should refresh agent-source/ and rebuild bundles. */
-const AGENT_REBUILD_TRIGGER_PATHS = [
- /^betterdesk-support-agent\//,
- /^betterdesk-agent\//,
- /^betterdesk-server\//,
- /^web-nodejs\/services\/agentBuildWorker\.js$/,
- /^web-nodejs\/services\/agentBundleConnection\.js$/,
- /^web-nodejs\/services\/agentBundleService\.js$/,
- /^web-nodejs\/routes\/generator\.routes\.js$/,
- /^scripts\/install-build-toolchain\.sh$/,
- /^betterdesk\.sh$/,
-];
-
-function shouldQueueAgentRebuild(changedData) {
- const all = Object.values(changedData?.grouped || {}).flat();
- return all.some((f) => AGENT_REBUILD_TRIGGER_PATHS.some((rx) => rx.test(f.path)));
+/** Legacy hook — Support Generator now uses Client templates, not Go agent-source. */
+function shouldQueueAgentRebuild(_changedData) {
+ return false;
}
/**
@@ -2546,7 +2526,7 @@ async function getChangedFiles(remoteSHA) {
const compare = await ghGet(`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/compare/${localSHA}...${remoteSHA}`);
const files = (compare.files || []).filter(f => !isExcluded(f.filename));
- const grouped = { console: [], server: [], agent: [], supportAgent: [], scripts: [], other: [] };
+ const grouped = { console: [], server: [], agent: [], scripts: [], other: [] };
for (const f of files) {
const comp = classifyFile(f.filename);
@@ -3254,28 +3234,6 @@ async function applyUpdate(remoteSHA, changedData, opts = {}) {
fs.writeFileSync(versionDest, versionContent);
} catch (_e) { /* non-critical */ }
- // ---- Support Agent generator: defer to post-restart phase ----
- // Agent Client and RdClient use their own workers. Keeping this rebuild
- // scoped prevents a Support Agent source update from invalidating their
- // ready artifacts, while legacy "agent" rows normalize to Support Agent.
- // The source tree can contain hundreds of files, so neither its sync nor
- // the queue operation may delay completion of the console update.
- if (shouldQueueAgentRebuild(changedData)) {
- try {
- const agentBuildWorker = require('./agentBuildWorker');
- agentBuildWorker.markRebuildPending('in-app update', { remoteSHA });
- results.agentRebuildDeferred = true;
- results.agentRebuildRemoteSHA = remoteSHA;
- results.agentRebuildProductType = 'support-agent';
- console.log(
- `[UPDATE] Support Agent rebuild deferred until console startup at ${remoteSHA.slice(0, 7)}`
- );
- } catch (err) {
- results.failed.push({ file: 'support-agent-rebuild-defer', error: err.message, nonCritical: true });
- console.warn(`[UPDATE] Could not defer Support Agent rebuild: ${err.message}`);
- }
- }
-
const finalFailures = splitUpdateFailures(results.failed, ROOT_DIR);
results.criticalFailures = finalFailures.critical;
results.nonCriticalFailures = finalFailures.nonCritical;
@@ -3313,14 +3271,9 @@ async function applyUpdate(remoteSHA, changedData, opts = {}) {
}
}
-/** Sync full support-agent trees from GitHub at the given commit SHA. */
-async function syncAgentSourceAtSha(remoteSHA) {
- const agentBuildWorker = require('./agentBuildWorker');
- return agentBuildWorker.syncFullAgentSourceFromGitHub({
- remoteSHA,
- download: ghDownloadFile,
- listPaths: ghListRepoBlobPaths,
- });
+/** @deprecated Support Generator no longer syncs Go support-agent source trees. */
+async function syncAgentSourceAtSha(_remoteSHA) {
+ return { staged: 0, paths: 0, skipped: true };
}
/**
diff --git a/web-nodejs/tests/agentBuildWorker.classify.test.js b/web-nodejs/tests/agentBuildWorker.classify.test.js
deleted file mode 100644
index c6592a75..00000000
--- a/web-nodejs/tests/agentBuildWorker.classify.test.js
+++ /dev/null
@@ -1,105 +0,0 @@
-'use strict';
-
-const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
-const fs = require('fs');
-const path = require('path');
-const os = require('os');
-
-describe('agentBuildWorker diagnostics', () => {
- let worker;
- let tmpDir;
-
- beforeEach(() => {
- jest.resetModules();
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bd-agent-build-'));
- process.env.BETTERDESK_DATA_DIR = tmpDir;
- // Re-require after env so config picks up data dir where possible
- worker = require('../services/agentBuildWorker');
- });
-
- afterEach(() => {
- try {
- fs.rmSync(tmpDir, { recursive: true, force: true });
- } catch (_) { /* ok */ }
- });
-
- it('classifyBuildError maps toolchain hints', () => {
- expect(worker.classifyBuildError('wixl: command not found').kind).toBe('wixl');
- expect(worker.classifyBuildError('Go toolchain broken').kind).toBe('go');
- expect(worker.classifyBuildError('appimagetool missing').kind).toBe('appimage');
- expect(worker.classifyBuildError('rpmbuild failed').kind).toBe('rpm');
- expect(worker.classifyBuildError('dpkg-deb error').kind).toBe('deb');
- expect(worker.classifyBuildError('mingw-w64 not found').kind).toBe('cgo');
- expect(worker.classifyBuildError('random compile fail').kind).toBe('compile');
- // Branding seal must win over incidental cgo/gcc noise from failed go run
- expect(worker.classifyBuildError(
- '# runtime/cgo\ngcc_linux_amd64.c: In function\nERROR: branding signing failed; refusing to embed plaintext'
- ).kind).toBe('branding_seal');
- expect(worker.classifyBuildError('sealbranding: signing key missing').kind).toBe('branding_seal');
- });
-
- it('getBuildWorkerStatus exposes mingw and appimagetool probes', () => {
- const status = worker.getBuildWorkerStatus();
- expect(status).toHaveProperty('mingwGcc');
- expect(status).toHaveProperty('appimagetool');
- expect(status).toHaveProperty('msiBuilder');
- });
-
- it('rejects malformed certificate pins in release profiles', () => {
- const profile = {
- bundle_id: 'bundle-test',
- profile_issued_at: '2026-01-01T00:00:00Z',
- profile_expires_at: '2099-01-01T00:00:00Z',
- allowed_endpoints: [
- 'https://support.example.test',
- 'https://support.example.test/api',
- 'wss://support.example.test:21122/cdap',
- ],
- server: {
- address: 'https://support.example.test',
- api_url: 'https://support.example.test/api',
- cdap_url: 'wss://support.example.test:21122/cdap',
- cert_pin: 'not-a-sha256-pin',
- },
- };
- expect(() => worker._internals.assertReleaseSupportProfile(profile)).toThrow(/incomplete or expired/i);
- });
-
- it('getBuildWorkerStatus exposes worker and platform matrix', () => {
- const status = worker.getBuildWorkerStatus();
- expect(status).toHaveProperty('workerEnabled');
- expect(status).toHaveProperty('goHealthy');
- expect(status).toHaveProperty('platforms');
- expect(Array.isArray(status.platforms)).toBe(true);
- expect(status.platforms.length).toBeGreaterThanOrEqual(6);
- const formats = status.platforms.map((p) => `${p.platform}/${p.format}`);
- expect(formats).toEqual(expect.arrayContaining([
- 'windows/portable',
- 'windows/installed',
- 'linux/portable',
- 'linux/appimage',
- 'linux/installed',
- 'linux/rpm',
- ]));
- });
-
- it('markRebuildPending then processPendingRebuild clears flag after requeue', async () => {
- worker.markRebuildPending('unit-test');
- const statusBefore = worker.getBuildWorkerStatus();
- expect(statusBefore.rebuildPending).toBeTruthy();
- expect(statusBefore.rebuildPending.reason).toBe('unit-test');
-
- // Stub requeue to avoid DB
- const orig = worker.requeueAllBundleBuilds;
- let called = false;
- worker.requeueAllBundleBuilds = async () => {
- called = true;
- return { bundles: 0 };
- };
- // processPendingRebuildOnStartup uses internal requeueAllBundleBuilds —
- // call through module's own function which closes over the real one.
- // Just verify flag file lifecycle via mark + get status.
- expect(called).toBe(false);
- worker.requeueAllBundleBuilds = orig;
- });
-});
diff --git a/web-nodejs/tests/agentBuildWorker.linuxPack.test.js b/web-nodejs/tests/agentBuildWorker.linuxPack.test.js
deleted file mode 100644
index 241de5f3..00000000
--- a/web-nodejs/tests/agentBuildWorker.linuxPack.test.js
+++ /dev/null
@@ -1,106 +0,0 @@
-'use strict';
-
-const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
-const fs = require('fs');
-const path = require('path');
-const os = require('os');
-
-describe('agentBuildWorker Linux packaging layout', () => {
- let worker;
- let tmpDir;
-
- beforeEach(() => {
- jest.resetModules();
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bd-agent-linux-pack-'));
- process.env.BETTERDESK_DATA_DIR = tmpDir;
- worker = require('../services/agentBuildWorker');
- });
-
- afterEach(() => {
- try {
- fs.rmSync(tmpDir, { recursive: true, force: true });
- } catch (_) { /* ok */ }
- });
-
- it('treats Wails single binary as non-dual layout', () => {
- const distDir = path.join(tmpDir, 'dist-single');
- fs.mkdirSync(distDir, { recursive: true });
- fs.writeFileSync(path.join(distDir, 'betterdesk-support'), 'bin');
- expect(worker._internals.hasDualLinuxUI(distDir)).toBe(false);
- });
-
- it('detects Fyne dual X11/Wayland layout', () => {
- const distDir = path.join(tmpDir, 'dist-dual');
- fs.mkdirSync(distDir, { recursive: true });
- fs.writeFileSync(path.join(distDir, 'betterdesk-support'), 'launcher');
- fs.writeFileSync(path.join(distDir, 'betterdesk-support-x11'), 'x11');
- fs.writeFileSync(path.join(distDir, 'betterdesk-support-wayland'), 'wl');
- expect(worker._internals.hasDualLinuxUI(distDir)).toBe(true);
- });
-
- it('stages only the single Wails binary when dual artifacts are absent', async () => {
- const distDir = path.join(tmpDir, 'dist-single');
- const stageDir = path.join(tmpDir, 'stage-single');
- fs.mkdirSync(distDir, { recursive: true });
- fs.mkdirSync(stageDir, { recursive: true });
- fs.writeFileSync(path.join(distDir, 'betterdesk-support'), 'single-bin');
-
- const layout = await worker._internals.stageLinuxUI(distDir, stageDir, 'betterdesk-support');
- expect(layout).toBe('single');
- expect(fs.existsSync(path.join(stageDir, 'betterdesk-support'))).toBe(true);
- expect(fs.existsSync(path.join(stageDir, 'betterdesk-support-x11'))).toBe(false);
- expect(fs.existsSync(path.join(stageDir, 'betterdesk-support-wayland'))).toBe(false);
- });
-
- it('stages dual Fyne binaries when present', async () => {
- const distDir = path.join(tmpDir, 'dist-dual');
- const stageDir = path.join(tmpDir, 'stage-dual');
- fs.mkdirSync(distDir, { recursive: true });
- fs.mkdirSync(stageDir, { recursive: true });
- fs.writeFileSync(path.join(distDir, 'betterdesk-support'), 'launcher');
- fs.writeFileSync(path.join(distDir, 'betterdesk-support-x11'), 'x11');
- fs.writeFileSync(path.join(distDir, 'betterdesk-support-wayland'), 'wl');
-
- const layout = await worker._internals.stageLinuxUI(distDir, stageDir, 'betterdesk-support');
- expect(layout).toBe('dual');
- expect(fs.readFileSync(path.join(stageDir, 'betterdesk-support-x11'), 'utf8')).toBe('x11');
- expect(fs.readFileSync(path.join(stageDir, 'betterdesk-support-wayland'), 'utf8')).toBe('wl');
- });
-
- it('needsCompile accepts a single Linux binary without x11/wayland companions', async () => {
- const workDir = path.join(tmpDir, 'work');
- const distDir = path.join(workDir, 'dist');
- fs.mkdirSync(distDir, { recursive: true });
- const binaryPath = path.join(distDir, 'betterdesk-support');
- fs.writeFileSync(binaryPath, 'bin');
- fs.writeFileSync(path.join(workDir, '.built_for'), 'fp-test');
-
- const needs = await worker._internals.needsCompile(workDir, 'fp-test', binaryPath, 'linux');
- expect(needs).toBe(false);
- });
-});
-
-describe('support-agent build.sh windows resource ordering', () => {
- it('generates winicon resources before sealbranding', () => {
- const buildSh = path.resolve(
- __dirname,
- '..',
- '..',
- 'betterdesk-support-agent',
- 'build.sh'
- );
- const src = fs.readFileSync(buildSh, 'utf8');
- const genIdx = src.indexOf('generate_windows_resources');
- // First executable call after the function definition — find the
- // standalone invocation (indented call, not the function keyword).
- const callMatch = src.match(/\nif \[ "\$TARGET_OS" = "windows" \]; then\n\s+generate_windows_resources\nfi/);
- expect(callMatch).not.toBeNull();
- const callIdx = callMatch.index;
- const sealIdx = src.indexOf('if seal_branding; then', callIdx);
- expect(sealIdx).toBeGreaterThan(callIdx);
- expect(genIdx).toBeGreaterThanOrEqual(0);
- // Ensure we do not call generate_windows_resources again after seal.
- const afterSeal = src.slice(sealIdx);
- expect(afterSeal).not.toMatch(/\ngenerate_windows_resources\n/);
- });
-});
diff --git a/web-nodejs/tests/agentBuildWorker.profileRefresh.test.js b/web-nodejs/tests/agentBuildWorker.profileRefresh.test.js
deleted file mode 100644
index 52d4a81f..00000000
--- a/web-nodejs/tests/agentBuildWorker.profileRefresh.test.js
+++ /dev/null
@@ -1,143 +0,0 @@
-'use strict';
-
-const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
-const fs = require('fs');
-const path = require('path');
-const os = require('os');
-
-describe('agentBuildWorker profile refresh on rebuild', () => {
- let worker;
- let tmpDir;
- let mockDb;
- let mockHash;
-
- beforeEach(() => {
- jest.resetModules();
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bd-agent-refresh-'));
- process.env.BETTERDESK_DATA_DIR = tmpDir;
-
- mockHash = jest.fn((branding) => `hash-${branding.profile_issued_at || 'none'}`);
- mockDb = {
- getAgentBundle: jest.fn(),
- listAgentBundles: jest.fn(),
- updateAgentBundle: jest.fn(async () => ({})),
- getAgentBundleBuild: jest.fn(async () => null),
- upsertAgentBundleBuild: jest.fn(async () => ({})),
- listAgentBundleBuildsForHash: jest.fn(async () => []),
- };
-
- jest.doMock('../services/database', () => mockDb);
- jest.doMock('../services/agentBundleService', () => ({
- PLATFORMS: [
- { platform: 'windows', arch: 'x86_64', format: 'portable' },
- { platform: 'linux', arch: 'x86_64', format: 'portable' },
- ],
- hashBranding: mockHash,
- publicBundleId: (row) => row.bundle_id,
- }));
- jest.doMock('../services/keyService', () => ({
- getPublicKey: () => 'test-pub-key',
- resolvePublicKey: async () => 'test-pub-key',
- }));
- jest.doMock('../services/agentBundleConnection', () => ({
- defaultServerHost: () => 'support.example.test',
- buildServerUrls: () => ({
- address: 'https://support.example.test',
- api_url: 'https://support.example.test/api',
- cdap_port: 21122,
- cdap_url: 'wss://support.example.test:21122/cdap',
- }),
- }));
-
- worker = require('../services/agentBuildWorker');
- });
-
- afterEach(() => {
- try {
- fs.rmSync(tmpDir, { recursive: true, force: true });
- } catch (_) { /* ok */ }
- jest.resetModules();
- });
-
- function expiredBundle() {
- return {
- bundle_id: 'bundle-1',
- name: 'Test',
- slug: 'test',
- product_type: 'support-agent',
- revoked: false,
- branding_hash: 'old-hash',
- branding: JSON.stringify({
- bundle_id: 'bundle-1',
- company_name: 'Acme',
- server_host: 'support.example.test',
- use_https: true,
- profile_issued_at: '2020-01-01T00:00:00.000Z',
- profile_expires_at: '2021-01-01T00:00:00.000Z',
- allowed_endpoints: ['https://a'],
- server: {
- address: 'https://support.example.test',
- api_url: 'https://support.example.test/api',
- cdap_url: 'wss://support.example.test:21122/cdap',
- },
- }),
- };
- }
-
- function validBundle() {
- const branding = {
- bundle_id: 'bundle-2',
- company_name: 'Acme',
- profile_issued_at: '2026-01-01T00:00:00.000Z',
- profile_expires_at: '2099-01-01T00:00:00.000Z',
- allowed_endpoints: [
- 'https://support.example.test',
- 'https://support.example.test/api',
- 'wss://support.example.test:21122/cdap',
- ],
- server: {
- address: 'https://support.example.test',
- api_url: 'https://support.example.test/api',
- cdap_url: 'wss://support.example.test:21122/cdap',
- },
- };
- return {
- bundle_id: 'bundle-2',
- name: 'Valid',
- slug: 'valid',
- product_type: 'support-agent',
- revoked: false,
- branding_hash: 'valid-hash',
- branding: JSON.stringify(branding),
- };
- }
-
- it('rebuildBundleById refreshes expired profile and enqueues new hash', async () => {
- mockDb.getAgentBundle.mockResolvedValue(expiredBundle());
- const result = await worker.rebuildBundleById('bundle-1');
- expect(result.success).toBe(true);
- expect(mockDb.updateAgentBundle).toHaveBeenCalledTimes(1);
- const updateArg = mockDb.updateAgentBundle.mock.calls[0][1];
- const saved = JSON.parse(updateArg.branding);
- expect(Date.parse(saved.profile_expires_at)).toBeGreaterThan(Date.now());
- expect(saved.allowed_endpoints.length).toBeGreaterThanOrEqual(3);
- expect(result.brandingHash).not.toBe('old-hash');
- expect(mockDb.upsertAgentBundleBuild).toHaveBeenCalled();
- expect(mockDb.upsertAgentBundleBuild.mock.calls[0][0].brandingHash).toBe(result.brandingHash);
- });
-
- it('rebuildBundleById keeps hash when profile is valid', async () => {
- mockDb.getAgentBundle.mockResolvedValue(validBundle());
- const result = await worker.rebuildBundleById('bundle-2');
- expect(result.success).toBe(true);
- expect(mockDb.updateAgentBundle).not.toHaveBeenCalled();
- expect(result.brandingHash).toBe('valid-hash');
- });
-
- it('requeueAllBundleBuilds refreshes stale profiles before enqueue', async () => {
- mockDb.listAgentBundles.mockResolvedValue([expiredBundle(), validBundle()]);
- const result = await worker.requeueAllBundleBuilds();
- expect(result.bundles).toBe(2);
- expect(mockDb.updateAgentBundle).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/web-nodejs/tests/agentBuildWorker.rebuild.test.js b/web-nodejs/tests/agentBuildWorker.rebuild.test.js
deleted file mode 100644
index 4cfe177e..00000000
--- a/web-nodejs/tests/agentBuildWorker.rebuild.test.js
+++ /dev/null
@@ -1,69 +0,0 @@
-'use strict';
-
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-
-describe('agentBuildWorker pending rebuild flag', () => {
- let dataDir;
- let origDataDir;
-
- beforeEach(() => {
- dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bd-agent-rebuild-'));
- origDataDir = process.env.DATA_DIR;
- process.env.DATA_DIR = dataDir;
- delete require.cache[require.resolve('../config/config')];
- delete require.cache[require.resolve('../services/agentBuildWorker')];
- });
-
- afterEach(() => {
- process.env.DATA_DIR = origDataDir;
- delete require.cache[require.resolve('../config/config')];
- delete require.cache[require.resolve('../services/agentBuildWorker')];
- if (dataDir && fs.existsSync(dataDir)) {
- fs.rmSync(dataDir, { recursive: true, force: true });
- }
- });
-
- it('markRebuildPending + processPendingRebuildOnStartup clears flag', async () => {
- const worker = require('../services/agentBuildWorker');
- worker.markRebuildPending('test');
- const flagPath = path.join(dataDir, '.agent_rebuild_pending');
- expect(fs.existsSync(flagPath)).toBe(true);
-
- const db = require('../services/database');
- const origList = db.listAgentBundles;
- db.listAgentBundles = async () => [];
-
- try {
- const result = await worker.processPendingRebuildOnStartup();
- expect(result.bundles).toBe(0);
- expect(result.reason).toBe('test');
- expect(fs.existsSync(flagPath)).toBe(false);
- } finally {
- db.listAgentBundles = origList;
- }
- });
-
- it('syncs the pending SHA before requeueing agent bundles', async () => {
- const updateService = require('../services/updateService');
- const sync = jest.spyOn(updateService, 'syncAgentSourceAtSha')
- .mockResolvedValue({ staged: 3, paths: 3 });
- const worker = require('../services/agentBuildWorker');
- const db = require('../services/database');
- const origList = db.listAgentBundles;
- db.listAgentBundles = async () => [];
-
- try {
- const remoteSHA = 'd'.repeat(40);
- worker.markRebuildPending('test-sha', { remoteSHA });
- const result = await worker.processPendingRebuildOnStartup();
- expect(sync).toHaveBeenCalledWith(remoteSHA);
- expect(result.source).toEqual({ staged: 3, paths: 3 });
- expect(result.remoteSHA).toBe(remoteSHA);
- } finally {
- db.listAgentBundles = origList;
- sync.mockRestore();
- }
- });
-});
diff --git a/web-nodejs/tests/agentBuildWorker.version.test.js b/web-nodejs/tests/agentBuildWorker.version.test.js
deleted file mode 100644
index b18e656a..00000000
--- a/web-nodejs/tests/agentBuildWorker.version.test.js
+++ /dev/null
@@ -1,50 +0,0 @@
-'use strict';
-
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-
-describe('agentBuildWorker product version injection', () => {
- let rootDir;
-
- beforeEach(() => {
- rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bd-agent-version-'));
- });
-
- afterEach(() => {
- fs.rmSync(rootDir, { recursive: true, force: true });
- });
-
- test('reads root VERSION and injects it into the copied Support Agent source', async () => {
- const workspace = path.join(rootDir, 'workspace');
- fs.mkdirSync(workspace, { recursive: true });
- fs.writeFileSync(path.join(rootDir, 'VERSION'), '7.6.5\n');
- fs.writeFileSync(
- path.join(workspace, 'main.go'),
- 'package main\n\nvar version = "0.1.0"\n'
- );
-
- const worker = require('../services/agentBuildWorker');
- const version = await worker._internals.injectSupportAgentVersion(workspace, { rootDir });
-
- expect(version).toBe('7.6.5');
- expect(worker._internals.getSupportAgentBuildVersion({ rootDir })).toBe('7.6.5');
- expect(fs.readFileSync(path.join(workspace, 'main.go'), 'utf8'))
- .toContain('var version = "7.6.5"');
- });
-
- test('inject succeeds when main.go already has the target version', async () => {
- const workspace = path.join(rootDir, 'workspace');
- fs.mkdirSync(workspace, { recursive: true });
- fs.writeFileSync(
- path.join(workspace, 'main.go'),
- 'package main\n\nvar version = "0.1.0"\n'
- );
-
- const worker = require('../services/agentBuildWorker');
- const version = await worker._internals.injectSupportAgentVersion(workspace, {
- version: '0.1.0',
- });
- expect(version).toBe('0.1.0');
- });
-});
diff --git a/web-nodejs/tests/bundleSigningKey.test.js b/web-nodejs/tests/bundleSigningKey.test.js
deleted file mode 100644
index 9e17ff51..00000000
--- a/web-nodejs/tests/bundleSigningKey.test.js
+++ /dev/null
@@ -1,48 +0,0 @@
-'use strict';
-
-const crypto = require('crypto');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-
-const {
- DEFAULT_KEY_FILE,
- resolveBundleSigningKeyFile,
- validateSigningKeyFile,
-} = require('../services/bundleSigningKey');
-
-describe('Support Agent branding signing key', () => {
- let rootDir;
-
- beforeEach(() => {
- rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bd-branding-key-'));
- });
-
- afterEach(() => {
- fs.rmSync(rootDir, { recursive: true, force: true });
- });
-
- test('creates a persistent Ed25519 key outside a bundle workspace', async () => {
- const keysPath = path.join(rootDir, 'keys');
- const first = await resolveBundleSigningKeyFile({ keysPath, env: {} });
- const firstPem = fs.readFileSync(first, 'utf8');
- const second = await resolveBundleSigningKeyFile({ keysPath, env: {} });
-
- expect(first).toBe(path.join(keysPath, DEFAULT_KEY_FILE));
- expect(second).toBe(first);
- expect(fs.readFileSync(second, 'utf8')).toBe(firstPem);
- await expect(validateSigningKeyFile(first)).resolves.toBeUndefined();
- expect(crypto.createPrivateKey(firstPem).asymmetricKeyType).toBe('ed25519');
- });
-
- test('rejects a configured key that is not Ed25519', async () => {
- const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
- const keyPath = path.join(rootDir, 'rsa.pem');
- fs.writeFileSync(keyPath, privateKey.export({ type: 'pkcs8', format: 'pem' }));
-
- await expect(resolveBundleSigningKeyFile({
- keysPath: path.join(rootDir, 'keys'),
- env: { BETTERDESK_BUNDLE_SIGNING_KEY_FILE: keyPath },
- })).rejects.toThrow('Ed25519');
- });
-});
diff --git a/web-nodejs/tests/customTxtBuilder.test.js b/web-nodejs/tests/customTxtBuilder.test.js
new file mode 100644
index 00000000..832792c2
--- /dev/null
+++ b/web-nodejs/tests/customTxtBuilder.test.js
@@ -0,0 +1,106 @@
+'use strict';
+
+const path = require('path');
+const fs = require('fs');
+const os = require('os');
+const {
+ buildSupportCustomTxt,
+ signCustomTxt,
+ buildAndSignSupportCustomTxt,
+} = require('../services/customTxtBuilder');
+
+describe('customTxtBuilder', () => {
+ test('builds Support Agent shaped JSON', () => {
+ const json = buildSupportCustomTxt({
+ appName: 'Acme Support',
+ host: 'desk.example.com',
+ relay: 'relay.example.com',
+ api: 'http://desk.example.com:21114',
+ key: 'PUBKEY',
+ disableSettings: true,
+ });
+ expect(json['app-name']).toBe('Acme Support');
+ expect(json['conn-type']).toBe('incoming');
+ expect(json['disable-settings']).toBe('Y');
+ expect(json['override-settings']['custom-rendezvous-server']).toBe('desk.example.com');
+ expect(json['override-settings']['relay-server']).toBe('relay.example.com');
+ expect(json['override-settings'].key).toBe('PUBKEY');
+ });
+
+ test('returns plain JSON when seed missing', () => {
+ const json = buildSupportCustomTxt({
+ host: 'h',
+ api: 'http://h:21114',
+ key: 'k',
+ });
+ const out = signCustomTxt(json, '');
+ expect(out.signed).toBe(false);
+ expect(out.content.startsWith('{')).toBe(true);
+ });
+
+ test('signs with 32-byte seed', () => {
+ const seed = Buffer.alloc(32, 7).toString('base64');
+ const result = buildAndSignSupportCustomTxt({
+ host: 'desk.example.com',
+ api: 'http://desk.example.com:21114',
+ key: 'k',
+ }, seed);
+ expect(result.signed).toBe(true);
+ expect(result.content.startsWith('{')).toBe(false);
+ expect(Buffer.from(result.content, 'base64').length).toBeGreaterThan(64);
+ });
+});
+
+describe('supportGeneratorModule state', () => {
+ let moduleDir;
+ let supportModule;
+ let prevDataDir;
+
+ beforeEach(() => {
+ moduleDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bd-gen-mod-'));
+ prevDataDir = process.env.BETTERDESK_DATA_DIR;
+ // config.dataDir is resolved at require-time in many places; stub via rewriting
+ // the module after setting a temp data dir through config mock is heavy —
+ // instead exercise helpers through a fresh require with DATA override when possible.
+ jest.resetModules();
+ jest.doMock('../config/config', () => ({
+ dataDir: moduleDir,
+ }));
+ supportModule = require('../services/supportGeneratorModule');
+ });
+
+ afterEach(() => {
+ jest.resetModules();
+ jest.dontMock('../config/config');
+ if (prevDataDir === undefined) delete process.env.BETTERDESK_DATA_DIR;
+ else process.env.BETTERDESK_DATA_DIR = prevDataDir;
+ fs.rmSync(moduleDir, { recursive: true, force: true });
+ });
+
+ test('starts not ready until terms + templates', async () => {
+ const status = await supportModule.getStatus();
+ expect(status.status).toBe('not_installed');
+ expect(status.ready).toBe(false);
+ await supportModule.acceptTerms();
+ const after = await supportModule.getStatus();
+ expect(after.termsAccepted).toBe(true);
+ expect(after.ready).toBe(false);
+ });
+
+ test('isReady when terms accepted, status ready, templates present', async () => {
+ await supportModule.acceptTerms();
+ const templates = supportModule.templatesDir();
+ fs.mkdirSync(path.join(templates, 'windows-x86_64'), { recursive: true });
+ fs.writeFileSync(path.join(templates, 'manifest.json'), '{"schema_version":1}\n');
+ const statePath = path.join(supportModule.moduleDir(), 'state.json');
+ fs.writeFileSync(statePath, JSON.stringify({
+ termsAccepted: true,
+ installedVersion: '1.0.0',
+ status: 'ready',
+ error: null,
+ installedAt: new Date().toISOString(),
+ }));
+ expect(supportModule.isReady()).toBe(true);
+ expect(supportModule.resolveTemplateDir('windows', 'x64')).toContain('windows-x86_64');
+ });
+});
diff --git a/web-nodejs/tests/dbAdapter.generatorBundles.test.js b/web-nodejs/tests/dbAdapter.generatorBundles.test.js
index 307b2bcc..c76159cd 100644
--- a/web-nodejs/tests/dbAdapter.generatorBundles.test.js
+++ b/web-nodejs/tests/dbAdapter.generatorBundles.test.js
@@ -89,9 +89,9 @@ describe('dbAdapter generator bundle compatibility', () => {
check.close();
expect(columns).toContain('product_type');
- expect(legacyRow.product_type).toBe('support-agent');
- expect(created.product_type).toBe('support-agent');
- expect(client.product_type).toBe('agent-client');
+ expect(legacyRow.product_type).toBe('betterdesk-support');
+ expect(created.product_type).toBe('betterdesk-support');
+ expect(client.product_type).toBe('betterdesk-support');
expect(build.status).toBe('queued');
});
});
diff --git a/web-nodejs/tests/generatorBuildTypes.test.js b/web-nodejs/tests/generatorBuildTypes.test.js
index 99a307ea..2071340e 100644
--- a/web-nodejs/tests/generatorBuildTypes.test.js
+++ b/web-nodejs/tests/generatorBuildTypes.test.js
@@ -8,14 +8,15 @@ const {
} = require('../lib/generatorBuildTypes');
describe('generator build type compatibility', () => {
- test('normalizes legacy product type aliases to canonical worker types', () => {
- expect(normalizeProductType()).toBe(PRODUCT_TYPES.SUPPORT_AGENT);
- expect(normalizeProductType('agent')).toBe(PRODUCT_TYPES.SUPPORT_AGENT);
- expect(normalizeProductType('support_agent')).toBe(PRODUCT_TYPES.SUPPORT_AGENT);
- expect(normalizeProductType('agent_client')).toBe(PRODUCT_TYPES.AGENT_CLIENT);
- expect(normalizeProductType('rdclient')).toBe(PRODUCT_TYPES.RDCLIENT);
- expect(normalizeProductType('unknown', PRODUCT_TYPES.AGENT_CLIENT))
- .toBe(PRODUCT_TYPES.AGENT_CLIENT);
+ test('normalizes legacy product type aliases to betterdesk-support', () => {
+ expect(normalizeProductType()).toBe(PRODUCT_TYPES.BETTERDESK_SUPPORT);
+ expect(normalizeProductType('agent')).toBe(PRODUCT_TYPES.BETTERDESK_SUPPORT);
+ expect(normalizeProductType('support_agent')).toBe(PRODUCT_TYPES.BETTERDESK_SUPPORT);
+ expect(normalizeProductType('support-agent')).toBe(PRODUCT_TYPES.BETTERDESK_SUPPORT);
+ expect(normalizeProductType('agent_client')).toBe(PRODUCT_TYPES.BETTERDESK_SUPPORT);
+ expect(normalizeProductType('rdclient')).toBe(PRODUCT_TYPES.BETTERDESK_SUPPORT);
+ expect(normalizeProductType('betterdesk-support')).toBe(PRODUCT_TYPES.BETTERDESK_SUPPORT);
+ expect(normalizeProductType('unknown')).toBe(PRODUCT_TYPES.BETTERDESK_SUPPORT);
});
test('treats queued and legacy pending jobs as the same queue state', () => {
diff --git a/web-nodejs/tests/generatorBuildWorkers.queue.test.js b/web-nodejs/tests/generatorBuildWorkers.queue.test.js
deleted file mode 100644
index a9032365..00000000
--- a/web-nodejs/tests/generatorBuildWorkers.queue.test.js
+++ /dev/null
@@ -1,113 +0,0 @@
-'use strict';
-
-jest.mock('../services/database', () => ({
- listAgentBundles: jest.fn(),
- listAgentBundleBuildsForHash: jest.fn(),
- getAgentBundleBuild: jest.fn(),
- upsertAgentBundleBuild: jest.fn(),
- getAgentBundle: jest.fn(),
- updateAgentBundle: jest.fn(),
-}));
-
-jest.mock('../services/agentBundleService', () => ({
- PLATFORMS: [
- { platform: 'linux', arch: 'x64', format: 'portable', label: 'Linux portable' },
- ],
- hashBranding: jest.fn((branding) => `hash-${branding.bundle_id || 'x'}`),
-}));
-
-jest.mock('../config/config', () => ({ dataDir: '/tmp/betterdesk-generator-worker-test' }));
-
-const db = require('../services/database');
-const supportWorker = require('../services/agentBuildWorker');
-const agentClientWorker = require('../services/agentClientBuildWorker');
-const rdclientWorker = require('../services/rdclientBuildWorker');
-
-const validSupportBranding = JSON.stringify({
- bundle_id: 'support-test',
- profile_issued_at: '2026-01-01T00:00:00.000Z',
- profile_expires_at: '2099-01-01T00:00:00.000Z',
- allowed_endpoints: [
- 'https://support.example.test',
- 'https://support.example.test/api',
- 'wss://support.example.test:21122/cdap',
- ],
- server: {
- address: 'https://support.example.test',
- api_url: 'https://support.example.test/api',
- cdap_url: 'wss://support.example.test:21122/cdap',
- },
-});
-
-const bundles = [
- {
- bundle_id: 'legacy-1',
- name: 'Legacy',
- slug: 'legacy',
- branding_hash: 'legacy-support',
- product_type: 'agent',
- revoked: false,
- branding: validSupportBranding,
- },
- {
- bundle_id: 'support-1',
- name: 'Support',
- slug: 'support',
- branding_hash: 'support',
- product_type: 'support-agent',
- revoked: false,
- branding: validSupportBranding,
- },
- { branding_hash: 'client', product_type: 'agent-client', revoked: false },
- { branding_hash: 'rdclient', product_type: 'rdclient', revoked: false },
-];
-
-const buildsByHash = {
- 'legacy-support': [{ branding_hash: 'legacy-support', status: 'queued', platform: 'linux', arch: 'x64', format: 'portable' }],
- support: [{ branding_hash: 'support', status: 'pending', platform: 'linux', arch: 'x64', format: 'portable' }],
- client: [{ branding_hash: 'client', status: 'queued', platform: 'linux', arch: 'x64', format: 'portable' }],
- rdclient: [{ branding_hash: 'rdclient', status: 'pending', platform: 'linux', arch: 'x64', format: 'portable' }],
-};
-
-describe('generator build workers', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- db.listAgentBundles.mockResolvedValue(bundles);
- db.listAgentBundleBuildsForHash.mockImplementation(async (hash) => buildsByHash[hash] || []);
- db.getAgentBundleBuild.mockResolvedValue(null);
- db.upsertAgentBundleBuild.mockResolvedValue({});
- db.updateAgentBundle.mockResolvedValue({});
- });
-
- test('claims both queued and pending records only for its product type', async () => {
- expect(typeof rdclientWorker.rebuildBundleById).toBe('function');
- expect(typeof rdclientWorker.requeuePlatformBuild).toBe('function');
- const support = await supportWorker._internals.listPendingBuilds(10);
- const client = await agentClientWorker._internals.listPendingBuilds(10);
- const rdclient = await rdclientWorker._internals.listPendingBuilds(10);
-
- expect(support.map((build) => build.branding_hash))
- .toEqual(expect.arrayContaining(['legacy-support', 'support']));
- expect(support).toHaveLength(2);
- expect(client.map((build) => build.branding_hash)).toEqual(['client']);
- expect(rdclient.map((build) => build.branding_hash)).toEqual(['rdclient']);
- });
-
- test('rebuild queues remain isolated by product worker', async () => {
- await supportWorker.requeueAllBundleBuilds();
- expect(db.upsertAgentBundleBuild.mock.calls.map(([job]) => job.brandingHash).sort())
- .toEqual(['legacy-support', 'support']);
- expect(db.upsertAgentBundleBuild.mock.calls.every(([job]) => job.status === 'queued')).toBe(true);
- expect(db.updateAgentBundle).not.toHaveBeenCalled();
-
- db.upsertAgentBundleBuild.mockClear();
- await agentClientWorker.requeueAllBundleBuilds();
- expect(db.upsertAgentBundleBuild.mock.calls.map(([job]) => job.brandingHash))
- .toEqual(['client']);
-
- db.upsertAgentBundleBuild.mockClear();
- await rdclientWorker.requeueAllBundleBuilds();
- expect(db.upsertAgentBundleBuild.mock.calls.map(([job]) => job.brandingHash))
- .toEqual(['rdclient']);
- });
-});
diff --git a/web-nodejs/tests/supportAgentProfile.test.js b/web-nodejs/tests/supportAgentProfile.test.js
deleted file mode 100644
index 622492f6..00000000
--- a/web-nodejs/tests/supportAgentProfile.test.js
+++ /dev/null
@@ -1,109 +0,0 @@
-'use strict';
-
-const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
-
-describe('supportAgentProfile', () => {
- let profile;
- const prevTtl = process.env.BETTERDESK_AGENT_PROFILE_TTL_DAYS;
-
- beforeEach(() => {
- jest.resetModules();
- process.env.BETTERDESK_AGENT_PROFILE_TTL_DAYS = '365';
- jest.doMock('../services/keyService', () => ({
- getPublicKey: () => 'test-pub-key',
- resolvePublicKey: async () => 'test-pub-key',
- }));
- jest.doMock('../services/agentBundleConnection', () => ({
- defaultServerHost: () => 'support.example.test',
- buildServerUrls: (host, useHttps) => {
- const scheme = useHttps ? 'https' : 'http';
- const ws = useHttps ? 'wss' : 'ws';
- return {
- address: `${scheme}://${host}`,
- api_url: `${scheme}://${host}/api`,
- cdap_port: 21122,
- cdap_url: `${ws}://${host}:21122/cdap`,
- };
- },
- }));
- profile = require('../services/supportAgentProfile');
- });
-
- afterEach(() => {
- if (prevTtl === undefined) delete process.env.BETTERDESK_AGENT_PROFILE_TTL_DAYS;
- else process.env.BETTERDESK_AGENT_PROFILE_TTL_DAYS = prevTtl;
- jest.resetModules();
- });
-
- function validProfile(overrides = {}) {
- return {
- bundle_id: 'bundle-test',
- profile_issued_at: '2026-01-01T00:00:00.000Z',
- profile_expires_at: '2099-01-01T00:00:00.000Z',
- allowed_endpoints: [
- 'https://support.example.test',
- 'https://support.example.test/api',
- 'wss://support.example.test:21122/cdap',
- ],
- server: {
- address: 'https://support.example.test',
- api_url: 'https://support.example.test/api',
- cdap_url: 'wss://support.example.test:21122/cdap',
- },
- ...overrides,
- };
- }
-
- it('accepts a complete future profile', () => {
- expect(profile.isReleaseSupportProfileValid(validProfile())).toBe(true);
- expect(() => profile.assertReleaseSupportProfile(validProfile())).not.toThrow();
- });
-
- it('rejects incomplete and expired profiles', () => {
- expect(profile.isReleaseSupportProfileValid({})).toBe(false);
- expect(profile.isReleaseSupportProfileValid(validProfile({
- profile_expires_at: '2020-01-01T00:00:00.000Z',
- }))).toBe(false);
- expect(profile.isReleaseSupportProfileValid(validProfile({
- allowed_endpoints: ['https://a', 'https://b'],
- }))).toBe(false);
- expect(profile.isReleaseSupportProfileValid(validProfile({
- server: {
- address: 'https://support.example.test',
- api_url: 'https://support.example.test/api',
- cdap_url: 'wss://support.example.test:21122/cdap',
- cert_pin: 'not-a-sha256-pin',
- },
- }))).toBe(false);
- });
-
- it('addSupportProfileValidity sets dates and at least 3 endpoints', () => {
- const branding = {
- server: {
- address: 'https://support.example.test',
- api_url: 'https://support.example.test/api',
- cdap_url: 'wss://support.example.test:21122/cdap',
- },
- };
- const now = new Date('2026-08-06T00:00:00.000Z');
- profile.addSupportProfileValidity(branding, now);
- expect(branding.profile_issued_at).toBe(now.toISOString());
- expect(Date.parse(branding.profile_expires_at)).toBeGreaterThan(now.getTime());
- expect(branding.allowed_endpoints.length).toBeGreaterThanOrEqual(3);
- });
-
- it('refreshSupportAgentBranding injects server urls and strips enrollment token', async () => {
- const out = await profile.refreshSupportAgentBranding({
- server_host: 'support.example.test',
- use_https: true,
- enrollment_token: 'secret',
- has_enrollment_token: true,
- });
- expect(out.server.address).toMatch(/^https:\/\//);
- expect(out.server.api_url).toMatch(/\/api$/);
- expect(out.server.cdap_url).toMatch(/^wss:\/\//);
- expect(out.server_key).toBe('test-pub-key');
- expect(out.enrollment_token).toBeUndefined();
- expect(out.has_enrollment_token).toBeUndefined();
- });
-});
diff --git a/web-nodejs/tests/updateService.agentRebuild.test.js b/web-nodejs/tests/updateService.agentRebuild.test.js
deleted file mode 100644
index f70a30d4..00000000
--- a/web-nodejs/tests/updateService.agentRebuild.test.js
+++ /dev/null
@@ -1,32 +0,0 @@
-'use strict';
-
-const updateService = require('../services/updateService');
-
-describe('updateService shouldQueueAgentRebuild', () => {
- it('triggers on support-agent source changes', () => {
- const data = {
- grouped: {
- supportAgent: [{ path: 'betterdesk-support-agent/urls.go' }],
- },
- };
- expect(updateService.shouldQueueAgentRebuild(data)).toBe(true);
- });
-
- it('triggers on build worker-only commits', () => {
- const data = {
- grouped: {
- console: [{ path: 'web-nodejs/services/agentBuildWorker.js' }],
- },
- };
- expect(updateService.shouldQueueAgentRebuild(data)).toBe(true);
- });
-
- it('ignores unrelated console changes', () => {
- const data = {
- grouped: {
- console: [{ path: 'web-nodejs/views/settings.ejs' }],
- },
- };
- expect(updateService.shouldQueueAgentRebuild(data)).toBe(false);
- });
-});
diff --git a/web-nodejs/views/generator.ejs b/web-nodejs/views/generator.ejs
index 2c83c5e5..48462796 100644
--- a/web-nodejs/views/generator.ejs
+++ b/web-nodejs/views/generator.ejs
@@ -13,7 +13,46 @@
${_('generator.subtitle')}
-
+
+
+
+
+
+ BetterDesk Support is an incoming-only desktop client (AGPL,
+ based on BetterDesk-Client). The Generator downloads portable templates from
+ GitHub Releases, then injects a server-locked custom.txt for each
+ platform. Appearance is loaded at runtime from the Client Branding API — not
+ baked into installers.
+
+
+ Incoming connections only (no outbound remote to other devices)
+ Templates: Windows / Linux / macOS (x64 + ARM64 portable)
+ Requires accepting AGPL terms before download
+
+
Checking module status…
+
+
+ gavel
+ Accept terms
+
+
+ download
+ Install from GitHub
+
+
+ check_circle
+ Finish installation
+
+
+
+
+
+