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 - - - -
-
-
- - -
-
BetterDesk Support
-
-
-
- -
- -
-
- - -
- -
-

Get support

-

Request help or chat with support. You can also share your ID and password below.

-
- - -
-
- - - -
-
-
Your ID
-
-
-
-
Password
-
-
-
- - -
-
-
- - - - -
- - - - - - - - - - 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(` - - - - Labelcom.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 ``; + }).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 = ``; } 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 ``; + }).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' + ? `` + : ''; + 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 ` - - `; - }).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 = ` - - - - - - - - - - ${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' - ? `` - : ''; - return ` - - - - - - `; - }).join('')} - -
${escapeText(t('generator.builds_title', 'Client builds'))}${escapeText(t('common.status', 'Status'))}
${escapeText(platformLabel(b.platform, b.arch, b.format))}${hint}${err}${escapeText(statusLabel(b.status))}${retry}
- `; - - 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')}

    -
    + + + + ` -}) %> - +}); %>