mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: finish the remote-desktop plugin (B14)
This commit is contained in:
1 parent
0af2c94983
commit
14eb558b85
215 files changed
+30029
-3921
No files matched your search
@@ -3,3 +3,15 @@
|
||||
## 1.0.0
|
||||
|
||||
Bundled with Termix 2.9.0.
|
||||
|
||||
- Runs entirely through the plugin SDK: no imports from Termix core.
|
||||
- Remote Desktop's admin switch and guacd URL, each user's RDP defaults and
|
||||
every host's RDP, VNC and Telnet options are this plugin's settings now,
|
||||
moved over on upgrade.
|
||||
- The user's RDP defaults now apply to saved hosts too, not only Quick Connect.
|
||||
- Provides `sessions.live` for RDP, VNC and Telnet, so these sessions can be
|
||||
shared and presented in collab rooms.
|
||||
- Jump host tunnels and the macOS VNC proxy close with their session instead
|
||||
of after an hour.
|
||||
- Opening the Windows Remote Desktop client goes through the desktop app's
|
||||
backend.
|
||||
@@ -1,68 +1,60 @@
|
||||
# Remote Desktop
|
||||
|
||||
RDP, VNC and Telnet remote desktop sessions, packaged as a first-party plugin.
|
||||
RDP, VNC and Telnet sessions over guacd, with clipboard, file transfer through
|
||||
RDP drive redirection, session recording and session sharing.
|
||||
|
||||
## Why this plugin runs in-process
|
||||
## guacd
|
||||
|
||||
Every other plugin runs in a `worker_threads` worker and reaches the server
|
||||
through a structured-clone `postMessage` boundary. That boundary is what makes
|
||||
the capability gate and the audit trail mean anything: a worker plugin cannot
|
||||
hold a db handle, an ssh2 `Client` or a live socket, so every privileged action
|
||||
has to go through the broker.
|
||||
guacd (the Apache Guacamole proxy daemon that speaks RDP, VNC and Telnet) is an
|
||||
external service this plugin talks to over TCP. The stock Docker compose runs
|
||||
it as its own `guacamole/guacd` container. This plugin never starts it.
|
||||
|
||||
This plugin cannot work that way:
|
||||
Where to find it is the admin setting **guacd URL** (`host:port`). The
|
||||
`GUACD_URL`, or `GUACD_HOST` and `GUACD_PORT`, environment variables override
|
||||
the setting, so a compose file stays the source of truth where it sets them.
|
||||
Changing the setting rebuilds the connection server without a restart.
|
||||
|
||||
- it owns a `guacamole-lite` WebSocket server on port 30008,
|
||||
- its `/connect-host/:hostId` route does multi-step credential and tunnel
|
||||
resolution (SSH jump-host tunnels, a macOS VNC compatibility proxy) using
|
||||
raw `net.Socket` plumbing,
|
||||
- it needs plaintext host credentials to connect at all.
|
||||
Other environment variables the plugin reads:
|
||||
|
||||
None of that can cross a structured-clone boundary, so this plugin runs on the
|
||||
main thread instead, under the `process:transport-owner` tier, the same as
|
||||
`ssh-terminal`, `docker` and `host-metrics`.
|
||||
| Variable | What it sets |
|
||||
| ------------------------------ | --------------------------------------------------------------------------- |
|
||||
| `GUACD_TUNNEL_HOST` | The name guacd uses to reach Termix for a jump host tunnel (default termix) |
|
||||
| `GUACD_RECORDING_PATH` | Where guacd writes recordings, as guacd sees it |
|
||||
| `GUACD_RECORDING_BACKEND_PATH` | The same folder as Termix sees it |
|
||||
| `GUACD_DRIVE_PATH` | The root of each user's RDP drive folder on the guacd host |
|
||||
| `GUACAMOLE_ENCRYPTION_KEY` | The connection token key; derived from `JWT_SECRET` when unset |
|
||||
|
||||
## guacd is not spawned by this plugin
|
||||
## Settings
|
||||
|
||||
guacd (the Apache Guacamole proxy daemon that actually speaks RDP/VNC/Telnet)
|
||||
is a separate, external service reached over plain TCP -- by default a
|
||||
sibling Docker container, or any network-reachable host configured through the
|
||||
admin "guacd URL" setting. This plugin does not download, bundle or spawn
|
||||
guacd as a child process.
|
||||
- **Admin**: turn Remote Desktop on or off, and the guacd URL.
|
||||
- **User**: RDP defaults (colour depth, resize method, wallpaper, clipboard
|
||||
and the rest). A host's own values win; anything a host leaves on its
|
||||
default takes the user's.
|
||||
- **Host**: the RDP, VNC and Telnet switches, ports, security mode,
|
||||
certificate handling and every guacd parameter, edited in the host
|
||||
editor's RDP, VNC and Telnet tabs. The toolbar switch is in the Plugins tab.
|
||||
|
||||
That is a deliberate limitation, not an oversight: Apache Guacamole does not
|
||||
publish prebuilt guacd binaries for any platform (only source tarballs and
|
||||
their own Docker image), and there is no viable native guacd for Windows at
|
||||
all. Bundling guacd for Linux and macOS is possible in principle but would
|
||||
make Termix the ongoing upstream builder and security patcher for guacd plus
|
||||
FreeRDP/libvncclient/libssh2/libtelnet -- out of scope for this conversion.
|
||||
guacd's availability is handled as an external dependency with graceful
|
||||
degradation, exactly as it was before this plugin existed.
|
||||
The logins (users, passwords, stored credentials, domain) stay on the host in
|
||||
core, because sharing decides which of them a recipient may use.
|
||||
|
||||
## What lives where
|
||||
## Capabilities
|
||||
|
||||
Unlike `ssh-terminal` (whose implementation stays in core because
|
||||
`session-manager.ts` has consumers well beyond the terminal), this plugin's
|
||||
backend and frontend code is fully relocated here from
|
||||
`src/backend/hosts/guacamole/` and `src/ui/features/guacamole/`, the same
|
||||
shape `docker` and `host-metrics` use.
|
||||
- `credentials:read`: guacd needs the host's RDP, VNC or Telnet password in
|
||||
plain text, so the plugin reads it through `ctx.credentials`. A shared
|
||||
recipient only ever gets the owner's shared snapshot or their own override.
|
||||
- `ssh:connect`, `credentials:use`: jump host tunnels, through
|
||||
`ctx.ssh.jumpChain`, or the tunnels plugin's `tunnels.access` for a single
|
||||
hop when it is running.
|
||||
- `network:serve`: the routes and the `/display` socket.
|
||||
- `desktop:window`: opening the Windows Remote Desktop client from the desktop
|
||||
app.
|
||||
- `hosts:read`: the online indicator while a session is open.
|
||||
- `ui:surface`: everything the frontend registers.
|
||||
|
||||
Two pieces of shared infrastructure still reach across the plugin boundary
|
||||
into this plugin rather than living in core:
|
||||
## Services
|
||||
|
||||
- `src/backend/hosts/collab/routes.ts` and
|
||||
`src/backend/hosts/session-sharing/` import `token-service.ts` and
|
||||
`guacamole-server.ts` directly to resolve and validate live Guacamole
|
||||
sessions for collaborative/shared viewing.
|
||||
- `src/ui/features/collab/` and `src/ui/features/session-sharing/` import
|
||||
`GuacamoleDisplay.tsx` directly for the same reason.
|
||||
|
||||
This is an accepted, temporary coupling: collab and session-sharing are
|
||||
expected to become plugins of their own later, at which point these become
|
||||
real inter-plugin references instead of raw relative imports.
|
||||
|
||||
## Disabling
|
||||
|
||||
Disabling behaves like any other plugin: the rdp/vnc/telnet tabs stop being
|
||||
offered from the host list, command palette and quick connect, the
|
||||
`/guacamole` router 404s, and the WebSocket server on port 30008 is closed.
|
||||
Provides `sessions.live` under the names `rdp`, `vnc` and `telnet`, which is
|
||||
how session sharing and collab rooms find a live session and mint a viewer
|
||||
token for it. Uses `recordings.writer` (session-recording) to decide whether
|
||||
to record and to list finished recordings, and `tunnels.access` (tunnels)
|
||||
for single-hop jump tunnels. Both are optional.
|
||||
@@ -73,7 +73,8 @@
|
||||
"fitToScreen": "Fit to screen",
|
||||
"switchToTrackpad": "Switch to trackpad mode (drag to move cursor, tap to click)",
|
||||
"switchToTouch": "Switch to touch mode (tap directly where you want to click)"
|
||||
}
|
||||
},
|
||||
"disabled": "Remote Desktop is turned off. An admin can turn it on in Settings."
|
||||
},
|
||||
"hosts": {
|
||||
"nativeRdpOpened": "Opened in Windows Remote Desktop",
|
||||
@@ -204,11 +205,134 @@
|
||||
"fontName": "Font Name",
|
||||
"fontSize": "Font Size",
|
||||
"colorScheme": "Color Scheme",
|
||||
"backspaceKey": "Backspace Key"
|
||||
}
|
||||
"backspaceKey": "Backspace Key",
|
||||
"colorDepth": "Color Depth",
|
||||
"resizeMethod": "Resize Method"
|
||||
},
|
||||
"remoteDesktop": "Remote Desktop",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Virtual Network",
|
||||
"unencryptedShell": "Unencrypted shell",
|
||||
"remoteDesktopNotes": "Notes",
|
||||
"userDefaultsNote": "Settings left on their default use your Remote Desktop defaults from Settings.",
|
||||
"connectionOriginNote": "On the desktop app, RDP, VNC and Telnet use the remote server unless the host is set to This device, which needs a guacd this machine can reach. Set one in the Remote Desktop settings, or per host under guacd Proxy."
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Remote desktop",
|
||||
"feature_desktop_desc": "Open RDP, VNC and Telnet sessions in a tab, same as SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Color Depth",
|
||||
"description": "Used for RDP hosts that leave it on their default."
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Resize Method",
|
||||
"description": "How the remote screen follows the window size."
|
||||
},
|
||||
"resizeDisplayUpdate": "Display Update",
|
||||
"resizeReconnect": "Reconnect",
|
||||
"inherit": "Inherit",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"forceLossless": {
|
||||
"label": "Force Lossless",
|
||||
"description": "Force lossless image encoding (higher quality, more bandwidth)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Wallpaper",
|
||||
"description": "Show desktop wallpaper (disabling improves performance)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Font Smoothing",
|
||||
"description": "Enable ClearType font rendering"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Desktop Composition",
|
||||
"description": "Enable Aero glass effects"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Disable Audio",
|
||||
"description": "Mute all audio from the remote session"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Enable Printing",
|
||||
"description": "Redirect local printers to the remote session"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Enable Drive Redirection",
|
||||
"description": "Map a local folder as a drive in the remote session"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Disable Copy",
|
||||
"description": "Prevent copying text from the remote session"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Disable Paste",
|
||||
"description": "Prevent pasting text into the remote session"
|
||||
},
|
||||
"group": "Remote Desktop defaults",
|
||||
"colorDepth16": "16-bit",
|
||||
"colorDepth24": "24-bit",
|
||||
"colorDepth32": "32-bit"
|
||||
},
|
||||
"admin": {
|
||||
"enabled": {
|
||||
"label": "Enable Remote Desktop",
|
||||
"description": "Allow RDP, VNC and Telnet connections through guacd."
|
||||
},
|
||||
"guacdUrl": {
|
||||
"label": "guacd URL",
|
||||
"description": "Where guacd listens, as host:port. The GUACD_URL, GUACD_HOST and GUACD_PORT environment variables override this.",
|
||||
"placeholder": "guacd:4822"
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
"enableRdp": {
|
||||
"label": "RDP"
|
||||
},
|
||||
"enableVnc": {
|
||||
"label": "VNC"
|
||||
},
|
||||
"enableTelnet": {
|
||||
"label": "Telnet"
|
||||
},
|
||||
"rdpPort": {
|
||||
"label": "RDP port"
|
||||
},
|
||||
"vncPort": {
|
||||
"label": "VNC port"
|
||||
},
|
||||
"telnetPort": {
|
||||
"label": "Telnet port"
|
||||
},
|
||||
"rdpSecurity": {
|
||||
"label": "RDP security mode"
|
||||
},
|
||||
"rdpIgnoreCert": {
|
||||
"label": "Ignore certificate errors"
|
||||
},
|
||||
"guacamoleConfig": {
|
||||
"label": "guacd settings"
|
||||
},
|
||||
"enableToolbar": {
|
||||
"label": "Remote desktop toolbar",
|
||||
"description": "Show the toolbar in RDP, VNC and Telnet sessions."
|
||||
}
|
||||
}
|
||||
},
|
||||
"palette": {
|
||||
"connectRdp": "Connect with RDP",
|
||||
"connectVnc": "Connect with VNC",
|
||||
"connectTelnet": "Connect with Telnet"
|
||||
},
|
||||
"permissions": {
|
||||
"sessions": {
|
||||
"title": "Share remote desktop sessions",
|
||||
"description": "Let session sharing and collab rooms show this user's RDP, VNC and Telnet sessions to others."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Lettertipe Naam",
|
||||
"fontSize": "Lettergrootte",
|
||||
"colorScheme": "Kleurskema",
|
||||
"backspaceKey": "Terugspasie-sleutel"
|
||||
}
|
||||
"backspaceKey": "Terugspasie-sleutel",
|
||||
"colorDepth": "Kleurdiepte",
|
||||
"resizeMethod": "Vergrootingsmetode"
|
||||
},
|
||||
"remoteDesktop": "Afstandslessenaar",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Virtuele Netwerk",
|
||||
"unencryptedShell": "Ongeënkripteerde dop"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Afstandlessenaar",
|
||||
"feature_desktop_desc": "Maak RDP-, VNC- en Telnet-sessies in 'n oortjie oop, dieselfde as SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Kleurdiepte"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Vergrootingsmetode"
|
||||
},
|
||||
"resizeDisplayUpdate": "Skermopdatering",
|
||||
"resizeReconnect": "Herkoppel",
|
||||
"inherit": "Erf",
|
||||
"on": "Aan",
|
||||
"off": "Af",
|
||||
"forceLossless": {
|
||||
"label": "Forseer verliesloos",
|
||||
"description": "Forseer verlieslose beeldkodering (hoër gehalte, meer bandwydte)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Agtergrond",
|
||||
"description": "Wys lessenaar agtergrond (deaktivering verbeter werkverrigting)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Lettertipe-gladmaak",
|
||||
"description": "Aktiveer ClearType-lettertipe-weergawe"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Komposisie op die lessenaar",
|
||||
"description": "Aktiveer Aero-glaseffekte"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Deaktiveer oudio",
|
||||
"description": "Demp alle klank van die afstandsessie"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Aktiveer drukwerk",
|
||||
"description": "Herlei plaaslike drukkers na die afgeleë sessie"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Aktiveer dryfherleiding",
|
||||
"description": "Karaktereer 'n plaaslike vouer as 'n skyf in die afstandsessie"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Deaktiveer Kopieer",
|
||||
"description": "Voorkom die kopiëring van teks vanaf die afgeleë sessie"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Deaktiveer plak",
|
||||
"description": "Voorkom die plak van teks in die afgeleë sessie"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "guacd-URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "اسم الخط",
|
||||
"fontSize": "حجم الخط",
|
||||
"colorScheme": "نظام الألوان",
|
||||
"backspaceKey": "مفتاح Backspace"
|
||||
}
|
||||
"backspaceKey": "مفتاح Backspace",
|
||||
"colorDepth": "عمق اللون",
|
||||
"resizeMethod": "طريقة تغيير الحجم"
|
||||
},
|
||||
"remoteDesktop": "سطح المكتب البعيد",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "الشبكة الافتراضية",
|
||||
"unencryptedShell": "shell غير مشفر"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "سطح المكتب البعيد",
|
||||
"feature_desktop_desc": "افتح جلسات RDP وVNC وTelnet في علامة تبويب، تمامًا مثل SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "عمق اللون"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "طريقة تغيير الحجم"
|
||||
},
|
||||
"resizeDisplayUpdate": "تحديث الشاشة",
|
||||
"resizeReconnect": "إعادة الاتصال",
|
||||
"inherit": "يرث",
|
||||
"on": "على",
|
||||
"off": "عن",
|
||||
"forceLossless": {
|
||||
"label": "فرض بدون فقدان",
|
||||
"description": "فرض ترميز الصور بدون فقدان (جودة أعلى، استهلاك أكبر للنطاق الترددي)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "خلفية سطح المكتب",
|
||||
"description": "إظهار خلفية سطح المكتب (التعطيل يحسن الأداء)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "تنعيم الخطوط",
|
||||
"description": "تمكين عرض خطوط ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "تركيب سطح المكتب",
|
||||
"description": "تمكين تأثيرات Aero الزجاجية"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "تعطيل الصوت",
|
||||
"description": "كتم كل الصوت من الجلسة البعيدة"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "تمكين الطباعة",
|
||||
"description": "إعادة توجيه الطابعات المحلية إلى الجلسة البعيدة"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "تمكين إعادة توجيه القرص",
|
||||
"description": "تعيين مجلد محلي كقرص في الجلسة البعيدة"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "تعطيل النسخ",
|
||||
"description": "منع نسخ النص من الجلسة البعيدة"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "تعطيل اللصق",
|
||||
"description": "منع لصق النص في الجلسة البعيدة"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "رابط guacd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Име на шрифта",
|
||||
"fontSize": "Размер на шрифта",
|
||||
"colorScheme": "Цветова схема",
|
||||
"backspaceKey": "Клавиш Backspace"
|
||||
}
|
||||
"backspaceKey": "Клавиш Backspace",
|
||||
"colorDepth": "Дълбочина на цвета",
|
||||
"resizeMethod": "Метод за преоразмеряване"
|
||||
},
|
||||
"remoteDesktop": "Отдалечен работен плот",
|
||||
"tabRdp": "ПРСР",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Телнет",
|
||||
"virtualNetwork": "Виртуална мрежа",
|
||||
"unencryptedShell": "Некриптирана обвивка"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Отдалечен работен плот",
|
||||
"feature_desktop_desc": "Отваряне на RDP, VNC и Telnet сесии в раздел, както при SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Дълбочина на цвета"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Метод за преоразмеряване"
|
||||
},
|
||||
"resizeDisplayUpdate": "Актуализация на дисплея",
|
||||
"resizeReconnect": "Свържете се отново",
|
||||
"inherit": "Наследяване",
|
||||
"on": "Включено",
|
||||
"off": "Изключено",
|
||||
"forceLossless": {
|
||||
"label": "Принудително беззагубно",
|
||||
"description": "Принудително кодиране на изображения без загуби (по-високо качество, по-голяма честотна лента)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Тапет",
|
||||
"description": "Показване на тапет на работния плот (деактивирането му подобрява производителността)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Изглаждане на шрифта",
|
||||
"description": "Активиране на рендирането на шрифтове ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Композиция на работния плот",
|
||||
"description": "Активиране на ефектите на Aero Glass"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Деактивиране на звука",
|
||||
"description": "Изключване на звука от отдалечената сесия"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Активиране на печат",
|
||||
"description": "Пренасочване на локални принтери към отдалечена сесия"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Активиране на пренасочване на устройството",
|
||||
"description": "Карта на локална папка като устройство в отдалечена сесия"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Деактивиране на копирането",
|
||||
"description": "Предотвратяване на копирането на текст от отдалечената сесия"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Деактивиране на поставянето",
|
||||
"description": "Предотвратяване на поставянето на текст в отдалечената сесия"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "URL адрес на GUACD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "ফন্টের নাম",
|
||||
"fontSize": "ফন্ট সাইজ",
|
||||
"colorScheme": "রঙের পরিকল্পনা",
|
||||
"backspaceKey": "ব্যাকস্পেস কী"
|
||||
}
|
||||
"backspaceKey": "ব্যাকস্পেস কী",
|
||||
"colorDepth": "রঙের গভীরতা",
|
||||
"resizeMethod": "আকার পরিবর্তন পদ্ধতি"
|
||||
},
|
||||
"remoteDesktop": "রিমোট ডেস্কটপ",
|
||||
"tabRdp": "আরডিপি",
|
||||
"tabVnc": "ভিএনসি",
|
||||
"tabTelnet": "টেলনেট",
|
||||
"virtualNetwork": "ভার্চুয়াল নেটওয়ার্ক",
|
||||
"unencryptedShell": "এনক্রিপ্টবিহীন শেল"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "রিমোট ডেস্কটপ",
|
||||
"feature_desktop_desc": "SSH-এর মতোই একটি ট্যাবে RDP, VNC এবং Telnet সেশন খুলুন।"
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "রঙের গভীরতা"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "আকার পরিবর্তন পদ্ধতি"
|
||||
},
|
||||
"resizeDisplayUpdate": "ডিসপ্লে আপডেট",
|
||||
"resizeReconnect": "পুনরায় সংযোগ করুন",
|
||||
"inherit": "উত্তরাধিকার সূত্রে",
|
||||
"on": "চালু",
|
||||
"off": "বন্ধ",
|
||||
"forceLossless": {
|
||||
"label": "বল ক্ষয়হীন",
|
||||
"description": "লসলেস ইমেজ এনকোডিং বাধ্যতামূলক করুন (উচ্চতর গুণমান, বেশি ব্যান্ডউইথ)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "ওয়ালপেপার",
|
||||
"description": "ডেস্কটপ ওয়ালপেপার দেখান (এটি বন্ধ করলে পারফরম্যান্স উন্নত হয়)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "ফন্ট মসৃণকরণ",
|
||||
"description": "ক্লিয়ারটাইপ ফন্ট রেন্ডারিং সক্ষম করুন"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "ডেস্কটপ কম্পোজিশন",
|
||||
"description": "এরো গ্লাস এফেক্টস সক্রিয় করুন"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "অডিও নিষ্ক্রিয় করুন",
|
||||
"description": "রিমোট সেশন থেকে সমস্ত অডিও মিউট করুন"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "প্রিন্টিং সক্ষম করুন",
|
||||
"description": "স্থানীয় প্রিন্টারগুলিকে রিমোট সেশনে পুনঃনির্দেশিত করুন"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "ড্রাইভ পুনঃনির্দেশনা সক্ষম করুন",
|
||||
"description": "রিমোট সেশনে একটি স্থানীয় ফোল্ডারকে ড্রাইভ হিসেবে ম্যাপ করুন।"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "কপি নিষ্ক্রিয় করুন",
|
||||
"description": "রিমোট সেশন থেকে টেক্সট কপি করা প্রতিরোধ করুন"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "পেস্ট নিষ্ক্রিয় করুন",
|
||||
"description": "রিমোট সেশনে টেক্সট পেস্ট করা প্রতিরোধ করুন"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "guacd URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Nom de la font",
|
||||
"fontSize": "Mida de la lletra",
|
||||
"colorScheme": "Esquema de colors",
|
||||
"backspaceKey": "Tecla Retrocés"
|
||||
}
|
||||
"backspaceKey": "Tecla Retrocés",
|
||||
"colorDepth": "Profunditat de color",
|
||||
"resizeMethod": "Mètode de redimensionament"
|
||||
},
|
||||
"remoteDesktop": "Escriptori remot",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Xarxa virtual",
|
||||
"unencryptedShell": "Shell sense xifrar"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Escriptori remot",
|
||||
"feature_desktop_desc": "Obriu sessions RDP, VNC i Telnet en una pestanya, igual que SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Profunditat de color"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Mètode de redimensionament"
|
||||
},
|
||||
"resizeDisplayUpdate": "Actualització de la pantalla",
|
||||
"resizeReconnect": "Reconnecta",
|
||||
"inherit": "Heretar",
|
||||
"on": "Activat",
|
||||
"off": "Desactivat",
|
||||
"forceLossless": {
|
||||
"label": "Força sense pèrdues",
|
||||
"description": "Força la codificació d'imatges sense pèrdues (més qualitat, més amplada de banda)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Fons de pantalla",
|
||||
"description": "Mostra el fons de pantalla de l'escriptori (desactivar-lo millora el rendiment)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Suavitzat de fonts",
|
||||
"description": "Activa la renderització de fonts ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Composició d'escriptori",
|
||||
"description": "Activa els efectes Aero Glass"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Desactiva l'àudio",
|
||||
"description": "Silencia tot l'àudio de la sessió remota"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Habilita la impressió",
|
||||
"description": "Redirigir les impressores locals a la sessió remota"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Activa la redirecció de la unitat",
|
||||
"description": "Assignar una carpeta local com a unitat a la sessió remota"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Desactiva la còpia",
|
||||
"description": "Impedir la còpia de text des de la sessió remota"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Desactiva l'enganxament",
|
||||
"description": "Impedir enganxar text a la sessió remota"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "URL de guacd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Název písma",
|
||||
"fontSize": "Velikost písma",
|
||||
"colorScheme": "Barevné schéma",
|
||||
"backspaceKey": "Klávesa Backspace"
|
||||
}
|
||||
"backspaceKey": "Klávesa Backspace",
|
||||
"colorDepth": "Barevná hloubka",
|
||||
"resizeMethod": "Metoda změny velikosti"
|
||||
},
|
||||
"remoteDesktop": "Vzdálená plocha",
|
||||
"tabRdp": "PRV",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Virtuální síť",
|
||||
"unencryptedShell": "Nešifrovaný shell"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Vzdálená plocha",
|
||||
"feature_desktop_desc": "Otevírejte relace RDP, VNC a Telnet na kartě, stejně jako SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Barevná hloubka"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Metoda změny velikosti"
|
||||
},
|
||||
"resizeDisplayUpdate": "Aktualizace displeje",
|
||||
"resizeReconnect": "Znovu se připojte",
|
||||
"inherit": "Zdědit",
|
||||
"on": "Na",
|
||||
"off": "Vypnuto",
|
||||
"forceLossless": {
|
||||
"label": "Vynutit bezztrátové",
|
||||
"description": "Vynucení bezztrátového kódování obrazu (vyšší kvalita, větší šířka pásma)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Tapeta",
|
||||
"description": "Zobrazit tapetu plochy (zakázání zlepšuje výkon)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Vyhlazování písma",
|
||||
"description": "Povolit vykreslování písma ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Složení plochy",
|
||||
"description": "Povolit efekty Aero Glass"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Zakázat zvuk",
|
||||
"description": "Ztlumit veškerý zvuk ze vzdálené relace"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Povolit tisk",
|
||||
"description": "Přesměrování lokálních tiskáren na vzdálenou relaci"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Povolit přesměrování disku",
|
||||
"description": "Namapování lokální složky jako jednotky ve vzdálené relaci"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Zakázat kopírování",
|
||||
"description": "Zabránit kopírování textu ze vzdálené relace"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Zakázat vkládání",
|
||||
"description": "Zabránit vkládání textu do vzdálené relace"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "URL GUACD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Skrifttypenavn",
|
||||
"fontSize": "Skriftstørrelse",
|
||||
"colorScheme": "Farveskema",
|
||||
"backspaceKey": "Tilbagetast"
|
||||
}
|
||||
"backspaceKey": "Tilbagetast",
|
||||
"colorDepth": "Farvedybde",
|
||||
"resizeMethod": "Ændre størrelse-metode"
|
||||
},
|
||||
"remoteDesktop": "Fjernskrivebord",
|
||||
"tabRdp": "Landdistriktsudviklingsplan",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Virtuelt netværk",
|
||||
"unencryptedShell": "Ukrypteret skal"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Fjernskrivebord",
|
||||
"feature_desktop_desc": "Åbn RDP-, VNC- og Telnet-sessioner i en fane, ligesom med SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Farvedybde"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Ændre størrelse-metode"
|
||||
},
|
||||
"resizeDisplayUpdate": "Opdatering af display",
|
||||
"resizeReconnect": "Genopret forbindelse",
|
||||
"inherit": "Arve",
|
||||
"on": "På",
|
||||
"off": "Slukket",
|
||||
"forceLossless": {
|
||||
"label": "Tving tabsfri",
|
||||
"description": "Tving tabsfri billedkodning (højere kvalitet, mere båndbredde)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Tapet",
|
||||
"description": "Vis skrivebordsbaggrund (deaktivering forbedrer ydeevnen)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Skrifttypeudjævning",
|
||||
"description": "Aktivér gengivelse af ClearType-skrifttyper"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Skrivebordskomposition",
|
||||
"description": "Aktivér Aero Glass-effekter"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Deaktiver lyd",
|
||||
"description": "Slå al lyd fra fra fjernsessionen"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Aktivér udskrivning",
|
||||
"description": "Omdiriger lokale printere til fjernsessionen"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Aktivér drevomdirigering",
|
||||
"description": "Tilknyt en lokal mappe som et drev i den eksterne session"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Deaktiver kopiering",
|
||||
"description": "Forhindr kopiering af tekst fra fjernsessionen"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Deaktiver indsætning",
|
||||
"description": "Forhindr indsættelse af tekst i fjernsessionen"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "guacd-URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Schriftname",
|
||||
"fontSize": "Schriftgröße",
|
||||
"colorScheme": "Farbschema",
|
||||
"backspaceKey": "Backspace-Taste"
|
||||
}
|
||||
"backspaceKey": "Backspace-Taste",
|
||||
"colorDepth": "Farbtiefe",
|
||||
"resizeMethod": "Skalierungsmethode"
|
||||
},
|
||||
"remoteDesktop": "Remotedesktop",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Virtuelles Netzwerk",
|
||||
"unencryptedShell": "Unverschlüsselte Shell"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Remote-Desktop",
|
||||
"feature_desktop_desc": "Öffnen Sie RDP-, VNC- und Telnet-Sitzungen in einem Tab, genau wie bei SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Farbtiefe"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Skalierungsmethode"
|
||||
},
|
||||
"resizeDisplayUpdate": "Anzeigeaktualisierung",
|
||||
"resizeReconnect": "Wiederverbinden",
|
||||
"inherit": "Erben",
|
||||
"on": "An",
|
||||
"off": "Aus",
|
||||
"forceLossless": {
|
||||
"label": "Verlustfreie Übertragung erzwingen",
|
||||
"description": "Verlustfreie Bildcodierung erzwingen (höhere Qualität, mehr Bandbreite)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Hintergrundbild",
|
||||
"description": "Desktop-Hintergrundbild anzeigen (Deaktivierung verbessert die Leistung)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Schriftglättung",
|
||||
"description": "ClearType-Schriftdarstellung aktivieren"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Desktopkomposition",
|
||||
"description": "Aero-Glaseffekte aktivieren"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Audio deaktivieren",
|
||||
"description": "Gesamten Ton der Remote-Sitzung stummschalten"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Drucken aktivieren",
|
||||
"description": "Lokale Drucker an die Remote-Sitzung umleiten"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Laufwerksumleitung aktivieren",
|
||||
"description": "Lokalen Ordner als Laufwerk in der Remote-Sitzung einbinden"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Kopieren deaktivieren",
|
||||
"description": "Kopieren von Text aus der entfernten Sitzung verhindern"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Einfügen deaktivieren",
|
||||
"description": "Einfügen von Text in die entfernte Sitzung verhindern"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "guacd-URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Όνομα γραμματοσειράς",
|
||||
"fontSize": "Μέγεθος γραμματοσειράς",
|
||||
"colorScheme": "Χρωματικό σχήμα",
|
||||
"backspaceKey": "Πλήκτρο Backspace"
|
||||
}
|
||||
"backspaceKey": "Πλήκτρο Backspace",
|
||||
"colorDepth": "Βάθος χρώματος",
|
||||
"resizeMethod": "Μέθοδος αλλαγής μεγέθους"
|
||||
},
|
||||
"remoteDesktop": "Απομακρυσμένη επιφάνεια εργασίας",
|
||||
"tabRdp": "ΠΑΑ",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Τέλνετ",
|
||||
"virtualNetwork": "Εικονικό Δίκτυο",
|
||||
"unencryptedShell": "Μη κρυπτογραφημένο κέλυφος"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Απομακρυσμένη επιφάνεια εργασίας",
|
||||
"feature_desktop_desc": "Ανοίξτε τις συνεδρίες RDP, VNC και Telnet σε μια καρτέλα, όπως και το SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Βάθος χρώματος"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Μέθοδος αλλαγής μεγέθους"
|
||||
},
|
||||
"resizeDisplayUpdate": "Ενημέρωση οθόνης",
|
||||
"resizeReconnect": "Επανασύνδεση",
|
||||
"inherit": "Κληρονομώ",
|
||||
"on": "Επί",
|
||||
"off": "Μακριά από",
|
||||
"forceLossless": {
|
||||
"label": "Δύναμη χωρίς απώλειες",
|
||||
"description": "Επιβολή κωδικοποίησης εικόνας χωρίς απώλειες (υψηλότερη ποιότητα, μεγαλύτερο εύρος ζώνης)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Ταπετσαρία",
|
||||
"description": "Εμφάνιση ταπετσαρίας επιφάνειας εργασίας (η απενεργοποίηση βελτιώνει την απόδοση)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Εξομάλυνση γραμματοσειράς",
|
||||
"description": "Ενεργοποίηση απόδοσης γραμματοσειράς ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Σύνθεση επιφάνειας εργασίας",
|
||||
"description": "Ενεργοποίηση εφέ Aero glass"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Απενεργοποίηση ήχου",
|
||||
"description": "Σίγαση όλου του ήχου από την απομακρυσμένη συνεδρία"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Ενεργοποίηση εκτύπωσης",
|
||||
"description": "Ανακατεύθυνση τοπικών εκτυπωτών στην απομακρυσμένη συνεδρία"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Ενεργοποίηση ανακατεύθυνσης μονάδας δίσκου",
|
||||
"description": "Αντιστοίχιση τοπικού φακέλου ως μονάδας δίσκου στην απομακρυσμένη συνεδρία"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Απενεργοποίηση αντιγραφής",
|
||||
"description": "Αποτροπή αντιγραφής κειμένου από την απομακρυσμένη συνεδρία"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Απενεργοποίηση επικόλλησης",
|
||||
"description": "Αποτροπή επικόλλησης κειμένου στην απομακρυσμένη συνεδρία"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "URL guacd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Nombre de fuente",
|
||||
"fontSize": "Tamaño de fuente",
|
||||
"colorScheme": "Esquema de color",
|
||||
"backspaceKey": "Tecla de retroceso"
|
||||
}
|
||||
"backspaceKey": "Tecla de retroceso",
|
||||
"colorDepth": "Profundidad de color",
|
||||
"resizeMethod": "Método de redimensionamiento"
|
||||
},
|
||||
"remoteDesktop": "Escritorio remoto",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Red virtual",
|
||||
"unencryptedShell": "Shell sin cifrar"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Escritorio remoto",
|
||||
"feature_desktop_desc": "Abre sesiones RDP, VNC y Telnet en una pestaña, igual que con SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Profundidad de color"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Método de redimensionamiento"
|
||||
},
|
||||
"resizeDisplayUpdate": "Actualización de pantalla",
|
||||
"resizeReconnect": "Reconectar",
|
||||
"inherit": "Heredar",
|
||||
"on": "En",
|
||||
"off": "Apagado",
|
||||
"forceLossless": {
|
||||
"label": "Forzar sin pérdida",
|
||||
"description": "Forzar codificación de imagen sin pérdida (más calidad, más ancho de banda)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Fondo de pantalla",
|
||||
"description": "Mostrar fondo de escritorio (desactivarlo mejora el rendimiento)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Suavizado de fuentes",
|
||||
"description": "Activar renderizado de fuentes ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Composición de escritorio",
|
||||
"description": "Activar efectos Aero Glass"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Desactivar audio",
|
||||
"description": "Silenciar todo el audio de la sesión remota"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Activar impresión",
|
||||
"description": "Redirigir las impresoras locales a la sesión remota"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Activar redirección de unidades",
|
||||
"description": "Asignar una carpeta local como una unidad en la sesión remota"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Deshabilitar copia",
|
||||
"description": "Impedir la copia de texto de la sesión remota"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Deshabilitar pegado",
|
||||
"description": "Impedir el pegado de texto en la sesión remota"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "URL de guacd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Fontin nimi",
|
||||
"fontSize": "Fonttikoko",
|
||||
"colorScheme": "Värimaailma",
|
||||
"backspaceKey": "Askelpalautin-näppäin"
|
||||
}
|
||||
"backspaceKey": "Askelpalautin-näppäin",
|
||||
"colorDepth": "Värisyvyys",
|
||||
"resizeMethod": "Koon muuttamismenetelmä"
|
||||
},
|
||||
"remoteDesktop": "Etätyöpöytä",
|
||||
"tabRdp": "Maaseudun kehittämisohjelma",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Virtuaaliverkko",
|
||||
"unencryptedShell": "Salaamaton kuori"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Etätyöpöytä",
|
||||
"feature_desktop_desc": "Avaa RDP-, VNC- ja Telnet-istunnot välilehdellä, samalla tavalla kuin SSH:ssa."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Värisyvyys"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Koon muuttamismenetelmä"
|
||||
},
|
||||
"resizeDisplayUpdate": "Näyttöpäivitys",
|
||||
"resizeReconnect": "Yhdistä uudelleen",
|
||||
"inherit": "Periä",
|
||||
"on": "Päällä",
|
||||
"off": "Pois",
|
||||
"forceLossless": {
|
||||
"label": "Pakota häviötön",
|
||||
"description": "Pakota häviötön kuvan koodaus (korkeampi laatu, enemmän kaistanleveyttä)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Taustakuva",
|
||||
"description": "Näytä työpöydän taustakuva (poistaminen käytöstä parantaa suorituskykyä)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Fontin pehmennys",
|
||||
"description": "Ota ClearType-fonttien renderöinti käyttöön"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Työpöydän sommittelu",
|
||||
"description": "Ota Aero Glass -tehosteet käyttöön"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Poista ääni käytöstä",
|
||||
"description": "Mykistä kaikki etäistunnon äänet"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Ota tulostus käyttöön",
|
||||
"description": "Ohjaa paikalliset tulostimet etäistuntoon"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Ota aseman uudelleenohjaus käyttöön",
|
||||
"description": "Yhdistä paikallinen kansio asemaksi etäistunnossa"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Poista kopiointi käytöstä",
|
||||
"description": "Estä tekstin kopioiminen etäistunnosta"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Poista liittäminen käytöstä",
|
||||
"description": "Estä tekstin liittäminen etäistuntoon"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "guacd-URL-osoite"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Nom de la police",
|
||||
"fontSize": "Taille de la police",
|
||||
"colorScheme": "Schéma de couleurs",
|
||||
"backspaceKey": "Touche Backspace"
|
||||
}
|
||||
"backspaceKey": "Touche Backspace",
|
||||
"colorDepth": "Profondeur de couleur",
|
||||
"resizeMethod": "Méthode de redimensionnement"
|
||||
},
|
||||
"remoteDesktop": "Bureau à distance",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Réseau virtuel",
|
||||
"unencryptedShell": "Shell non chiffré"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Bureau à distance",
|
||||
"feature_desktop_desc": "Ouvrez les sessions RDP, VNC et Telnet dans un onglet, de la même manière que SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Profondeur de couleur"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Méthode de redimensionnement"
|
||||
},
|
||||
"resizeDisplayUpdate": "Mise à jour de l'affichage",
|
||||
"resizeReconnect": "Reconnect",
|
||||
"inherit": "Hériter",
|
||||
"on": "Sur",
|
||||
"off": "Désactivé",
|
||||
"forceLossless": {
|
||||
"label": "Forcer sans perte",
|
||||
"description": "Forcer l'encodage d'image sans perte (meilleure qualité, plus de bande passante)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Fond d'écran",
|
||||
"description": "Afficher le fond d'écran du bureau (désactiver cette option améliore les performances)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Lissage des polices",
|
||||
"description": "Activer le rendu des polices ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Composition du bureau",
|
||||
"description": "Activer les effets Aero Glass"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Désactiver l'audio",
|
||||
"description": "Couper tout le son de la session distante"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Activer l'impression",
|
||||
"description": "Rediriger les imprimantes locales vers la session distante"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Activer la redirection de lecteur",
|
||||
"description": "Monter un dossier local en tant que lecteur dans la session distante"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Désactiver la copie",
|
||||
"description": "Empêcher la copie de texte depuis la session distante"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Désactiver le collage",
|
||||
"description": "Empêcher le collage de texte dans la session distante"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "URL guacd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "שם הגופן",
|
||||
"fontSize": "גודל גופן",
|
||||
"colorScheme": "ערכת צבעים",
|
||||
"backspaceKey": "מקש Backspace"
|
||||
}
|
||||
"backspaceKey": "מקש Backspace",
|
||||
"colorDepth": "עומק צבע",
|
||||
"resizeMethod": "שיטת שינוי גודל"
|
||||
},
|
||||
"remoteDesktop": "שולחן עבודה מרוחק",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "טלנט",
|
||||
"virtualNetwork": "רשת וירטואלית",
|
||||
"unencryptedShell": "מעטפת לא מוצפנת"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "שולחן עבודה מרוחק",
|
||||
"feature_desktop_desc": "פתחו הפעלות RDP, VNC ו-Telnet בכרטיסייה, בדומה ל-SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "עומק צבע"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "שיטת שינוי גודל"
|
||||
},
|
||||
"resizeDisplayUpdate": "עדכון תצוגה",
|
||||
"resizeReconnect": "התחבר מחדש",
|
||||
"inherit": "לָרֶשֶׁת",
|
||||
"on": "עַל",
|
||||
"off": "כבוי",
|
||||
"forceLossless": {
|
||||
"label": "כפיית אובדן נתונים",
|
||||
"description": "כפיית קידוד תמונה ללא אובדן נתונים (איכות גבוהה יותר, רוחב פס גדול יותר)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "טָפֵּט",
|
||||
"description": "הצגת טפט שולחן עבודה (השבתה משפרת את הביצועים)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "החלקת גופנים",
|
||||
"description": "הפעלת עיבוד גופני ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "קומפוזיציה לשולחן העבודה",
|
||||
"description": "הפעלת אפקטים של Aero Glass"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "השבתת שמע",
|
||||
"description": "השתקת כל האודיו מההפעלה המרוחקת"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "הפעל הדפסה",
|
||||
"description": "ניתוב מחדש של מדפסות מקומיות להפעלה מרוחקת"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "הפעל ניתוב מחדש של כונן",
|
||||
"description": "מיפוי תיקייה מקומית ככונן בהפעלה מרוחקת"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "השבתת העתקה",
|
||||
"description": "מניעת העתקת טקסט מההפעלה המרוחקת"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "השבתת הדבקה",
|
||||
"description": "מניעת הדבקת טקסט לתוך ההפעלה המרוחקת"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "כתובת אתר של guacd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "फ़ॉन्ट नाम",
|
||||
"fontSize": "फ़ॉन्ट आकार",
|
||||
"colorScheme": "रंग योजना",
|
||||
"backspaceKey": "बैकस्पेस कुंजी"
|
||||
}
|
||||
"backspaceKey": "बैकस्पेस कुंजी",
|
||||
"colorDepth": "रंग गहराई",
|
||||
"resizeMethod": "रीसाइज़ विधि"
|
||||
},
|
||||
"remoteDesktop": "दूरस्थ डेस्कटॉप",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "वर्चुअल नेटवर्क",
|
||||
"unencryptedShell": "अनएन्क्रिप्टेड शेल"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "दूरवर्ती डेस्कटॉप",
|
||||
"feature_desktop_desc": "RDP, VNC और Telnet सेशन को SSH की तरह ही एक टैब में खोलें।"
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "रंग गहराई"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "रीसाइज़ विधि"
|
||||
},
|
||||
"resizeDisplayUpdate": "डिस्प्ले अपडेट",
|
||||
"resizeReconnect": "रिकनेक्ट",
|
||||
"inherit": "इनहेरिट",
|
||||
"on": "पर",
|
||||
"off": "बंद",
|
||||
"forceLossless": {
|
||||
"label": "दोषरहित बाध्य करें",
|
||||
"description": "दोषरहित छवि एन्कोडिंग बाध्य करें (उच्च गुणवत्ता, अधिक बैंडविड्थ)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "वॉलपेपर",
|
||||
"description": "डेस्कटॉप वॉलपेपर दिखाएं (अक्षम करने से प्रदर्शन बेहतर होता है)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "फ़ॉन्ट स्मूथिंग",
|
||||
"description": "ClearType फ़ॉन्ट रेंडरिंग सक्षम करें"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "डेस्कटॉप कंपोज़िशन",
|
||||
"description": "Aero ग्लास प्रभाव सक्षम करें"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "ऑडियो अक्षम करें",
|
||||
"description": "दूरस्थ सत्र से सभी ऑडियो म्यूट करें"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "प्रिंटिंग सक्षम करें",
|
||||
"description": "स्थानीय प्रिंटर को दूरस्थ सत्र में पुनर्निर्देशित करें"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "ड्राइव पुनर्निर्देशन सक्षम करें",
|
||||
"description": "स्थानीय फ़ोल्डर को दूरस्थ सत्र में ड्राइव के रूप में मैप करें"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "कॉपी अक्षम करें",
|
||||
"description": "रिमोट सत्र से टेक्स्ट कॉपी करना रोकें"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "पेस्ट अक्षम करें",
|
||||
"description": "रिमोट सत्र में टेक्स्ट पेस्ट करना रोकें"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "guacd URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Betűtípus neve",
|
||||
"fontSize": "Betűméret",
|
||||
"colorScheme": "Színséma",
|
||||
"backspaceKey": "Visszatörlés billentyű"
|
||||
}
|
||||
"backspaceKey": "Visszatörlés billentyű",
|
||||
"colorDepth": "Színmélység",
|
||||
"resizeMethod": "Átméretezési módszer"
|
||||
},
|
||||
"remoteDesktop": "Távoli asztal",
|
||||
"tabRdp": "Vidékfejlesztési Program",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Virtuális hálózat",
|
||||
"unencryptedShell": "Titkosítatlan shell"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Távoli asztal",
|
||||
"feature_desktop_desc": "Nyisson meg RDP, VNC és Telnet munkameneteket egy lapon, ugyanúgy, mint az SSH-n."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Színmélység"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Átméretezési módszer"
|
||||
},
|
||||
"resizeDisplayUpdate": "Kijelzőfrissítés",
|
||||
"resizeReconnect": "Újracsatlakozás",
|
||||
"inherit": "Örököl",
|
||||
"on": "On",
|
||||
"off": "Le",
|
||||
"forceLossless": {
|
||||
"label": "Veszteségmentes erő",
|
||||
"description": "Veszteségmentes képkódolás kényszerítése (jobb minőség, nagyobb sávszélesség)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Tapéta",
|
||||
"description": "Asztali háttérkép megjelenítése (a letiltás javítja a teljesítményt)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Betűsimítás",
|
||||
"description": "ClearType betűtípus-megjelenítés engedélyezése"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Asztali kompozíció",
|
||||
"description": "Aero Glass effektek engedélyezése"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Hang letiltása",
|
||||
"description": "Némítsa el az összes hangot a távoli munkamenetből"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Nyomtatás engedélyezése",
|
||||
"description": "Helyi nyomtatók átirányítása a távoli munkamenetre"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Meghajtó átirányításának engedélyezése",
|
||||
"description": "Helyi mappa meghajtóként való leképezése a távoli munkamenetben"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Másolás letiltása",
|
||||
"description": "Szöveg másolásának megakadályozása a távoli munkamenetből"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Beillesztés letiltása",
|
||||
"description": "Szöveg beillesztésének megakadályozása a távoli munkamenetbe"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "guacd URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Nama Font",
|
||||
"fontSize": "Ukuran Huruf",
|
||||
"colorScheme": "Skema Warna",
|
||||
"backspaceKey": "Tombol Backspace"
|
||||
}
|
||||
"backspaceKey": "Tombol Backspace",
|
||||
"colorDepth": "Kedalaman Warna",
|
||||
"resizeMethod": "Metode Pengubahan Ukuran"
|
||||
},
|
||||
"remoteDesktop": "Desktop Jarak Jauh",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Jaringan Virtual",
|
||||
"unencryptedShell": "Shell yang tidak terenkripsi"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Desktop jarak jauh",
|
||||
"feature_desktop_desc": "Buka sesi RDP, VNC, dan Telnet dalam satu tab, sama seperti SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Kedalaman Warna"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Metode Pengubahan Ukuran"
|
||||
},
|
||||
"resizeDisplayUpdate": "Pembaruan Tampilan",
|
||||
"resizeReconnect": "Terhubung kembali",
|
||||
"inherit": "Mewarisi",
|
||||
"on": "Pada",
|
||||
"off": "Mati",
|
||||
"forceLossless": {
|
||||
"label": "Paksa Tanpa Kehilangan",
|
||||
"description": "Paksa pengkodean gambar tanpa kehilangan data (kualitas lebih tinggi, bandwidth lebih besar)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Wallpaper",
|
||||
"description": "Tampilkan wallpaper desktop (menonaktifkannya akan meningkatkan kinerja)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Penghalusan Font",
|
||||
"description": "Aktifkan rendering font ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Komposisi Desktop",
|
||||
"description": "Aktifkan efek kaca Aero"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Nonaktifkan Audio",
|
||||
"description": "Nonaktifkan semua audio dari sesi jarak jauh."
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Aktifkan Pencetakan",
|
||||
"description": "Alihkan printer lokal ke sesi jarak jauh."
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Aktifkan Pengalihan Drive",
|
||||
"description": "Petakan folder lokal sebagai drive di sesi jarak jauh."
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Nonaktifkan Salin",
|
||||
"description": "Mencegah penyalinan teks dari sesi jarak jauh"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Nonaktifkan Tempel",
|
||||
"description": "Mencegah penyisipan teks ke dalam sesi jarak jauh"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "URL guacd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Nome font",
|
||||
"fontSize": "Dimensione font",
|
||||
"colorScheme": "Schema colori",
|
||||
"backspaceKey": "Tasto Backspace"
|
||||
}
|
||||
"backspaceKey": "Tasto Backspace",
|
||||
"colorDepth": "Profondità colore",
|
||||
"resizeMethod": "Metodo di ridimensionamento"
|
||||
},
|
||||
"remoteDesktop": "Desktop remoto",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Rete virtuale",
|
||||
"unencryptedShell": "Shell non crittografata"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Desktop remoto",
|
||||
"feature_desktop_desc": "Apri le sessioni RDP, VNC e Telnet in una scheda, proprio come per SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Profondità colore"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Metodo di ridimensionamento"
|
||||
},
|
||||
"resizeDisplayUpdate": "Aggiornamento della visualizzazione",
|
||||
"resizeReconnect": "Ricollega",
|
||||
"inherit": "Ereditare",
|
||||
"on": "SU",
|
||||
"off": "Spento",
|
||||
"forceLossless": {
|
||||
"label": "Forza lossless",
|
||||
"description": "Forza la codifica lossless delle immagini (qualità più alta, maggiore utilizzo di banda)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Sfondo",
|
||||
"description": "Mostra lo sfondo del desktop (disabilitarlo migliora le prestazioni)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Smussamento caratteri",
|
||||
"description": "Abilita il rendering dei caratteri ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Composizione desktop",
|
||||
"description": "Abilita gli effetti Aero Glass"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Disabilita audio",
|
||||
"description": "Disattiva tutto l'audio dalla sessione remota"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Abilita stampa",
|
||||
"description": "Reindirizza le stampanti locali alla sessione remota"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Abilita reindirizzamento unità",
|
||||
"description": "Mappa una cartella locale come unità nella sessione remota"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Disabilita copia",
|
||||
"description": "Impedisce la copia di testo dalla sessione remota"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Disabilita incolla",
|
||||
"description": "Impedisce di incollare testo nella sessione remota"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "URL guacd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "フォント名",
|
||||
"fontSize": "フォントサイズ",
|
||||
"colorScheme": "カラースキーム",
|
||||
"backspaceKey": "バックスペースキー"
|
||||
}
|
||||
"backspaceKey": "バックスペースキー",
|
||||
"colorDepth": "色深度",
|
||||
"resizeMethod": "リサイズ方法"
|
||||
},
|
||||
"remoteDesktop": "リモートデスクトップ",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "仮想ネットワーク",
|
||||
"unencryptedShell": "暗号化されていないシェル"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "リモートデスクトップ",
|
||||
"feature_desktop_desc": "RDP、VNC、Telnetのセッションは、SSHと同様にタブで開きます。"
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "色深度"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "リサイズ方法"
|
||||
},
|
||||
"resizeDisplayUpdate": "ディスプレイの更新",
|
||||
"resizeReconnect": "再接続",
|
||||
"inherit": "継承する",
|
||||
"on": "の上",
|
||||
"off": "オフ",
|
||||
"forceLossless": {
|
||||
"label": "ロスレスを強制",
|
||||
"description": "ロスレス画像エンコーディングを強制する(高品質だが帯域幅を多く消費)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "壁紙",
|
||||
"description": "デスクトップ壁紙を表示する(無効にするとパフォーマンスが向上)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "フォントスムージング",
|
||||
"description": "ClearTypeフォントレンダリングを有効にする"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "デスクトップコンポジション",
|
||||
"description": "Aero Glass効果を有効にする"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "オーディオを無効化",
|
||||
"description": "リモートセッションの音声をすべてミュートする"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "印刷を有効化",
|
||||
"description": "ローカルプリンターをリモートセッションにリダイレクトする"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "ドライブリダイレクションを有効化",
|
||||
"description": "リモートセッションでローカルフォルダーをドライブとしてマッピングする"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "コピーを無効にする",
|
||||
"description": "リモートセッションからのテキストコピーを禁止します"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "貼り付けを無効にする",
|
||||
"description": "リモートセッションへのテキスト貼り付けを禁止します"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "guacd URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "글꼴 이름",
|
||||
"fontSize": "글꼴 크기",
|
||||
"colorScheme": "색 구성표",
|
||||
"backspaceKey": "백스페이스 키"
|
||||
}
|
||||
"backspaceKey": "백스페이스 키",
|
||||
"colorDepth": "색 농도",
|
||||
"resizeMethod": "크기 조정 방법"
|
||||
},
|
||||
"remoteDesktop": "원격 데스크톱",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "텔넷",
|
||||
"virtualNetwork": "가상 네트워크",
|
||||
"unencryptedShell": "암호화되지 않은 셸"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "원격 데스크톱",
|
||||
"feature_desktop_desc": "SSH와 마찬가지로 RDP, VNC 및 Telnet 세션을 탭에서 열 수 있습니다."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "색 농도"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "크기 조정 방법"
|
||||
},
|
||||
"resizeDisplayUpdate": "디스플레이 업데이트",
|
||||
"resizeReconnect": "다시 연결",
|
||||
"inherit": "상속하다",
|
||||
"on": "~에",
|
||||
"off": "끄다",
|
||||
"forceLossless": {
|
||||
"label": "무손실 강제",
|
||||
"description": "무손실 이미지 인코딩 강제 (더 높은 품질, 더 많은 대역폭)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "배경 화면",
|
||||
"description": "바탕 화면 배경 표시 (비활성화 시 성능 향상)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "글꼴 다듬기",
|
||||
"description": "ClearType 글꼴 렌더링 사용"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "데스크톱 구성",
|
||||
"description": "Aero 글래스 효과 활성화"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "오디오 비활성화",
|
||||
"description": "원격 세션의 모든 오디오 음소거"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "인쇄 활성화",
|
||||
"description": "로컬 프린터를 원격 세션으로 리디렉션"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "드라이브 리디렉션 활성화",
|
||||
"description": "로컬 폴더를 원격 세션에서 드라이브로 매핑"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "복사 비활성화",
|
||||
"description": "원격 세션에서 텍스트 복사 방지"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "붙여넣기 비활성화",
|
||||
"description": "원격 세션으로 텍스트 붙여넣기 방지"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "guacd URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Lettertypenaam",
|
||||
"fontSize": "Lettergrootte",
|
||||
"colorScheme": "Kleurenschema",
|
||||
"backspaceKey": "Backspace-toets"
|
||||
}
|
||||
"backspaceKey": "Backspace-toets",
|
||||
"colorDepth": "Kleurdiepte",
|
||||
"resizeMethod": "Formaatwijzigingsmethode"
|
||||
},
|
||||
"remoteDesktop": "Extern bureaublad",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Virtueel netwerk",
|
||||
"unencryptedShell": "Niet-versleutelde shell"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Extern bureaublad",
|
||||
"feature_desktop_desc": "Open RDP-, VNC- en Telnet-sessies in een tabblad, net als bij SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Kleurdiepte"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Formaatwijzigingsmethode"
|
||||
},
|
||||
"resizeDisplayUpdate": "Weergave bijwerken",
|
||||
"resizeReconnect": "Opnieuw verbinding maken",
|
||||
"inherit": "Erven",
|
||||
"on": "Op",
|
||||
"off": "Uit",
|
||||
"forceLossless": {
|
||||
"label": "Krachtverliesloos",
|
||||
"description": "Forceer verliesvrije beeldcodering (hogere kwaliteit, meer bandbreedte)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Behang",
|
||||
"description": "Toon bureaubladachtergrond (uitschakelen verbetert de prestaties)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Lettertype-vloeiendheid",
|
||||
"description": "Schakel ClearType-lettertypeweergave in"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Bureaubladcompositie",
|
||||
"description": "Schakel Aero-glaseffecten in"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Geluid uitschakelen",
|
||||
"description": "Demp alle audio van de externe sessie."
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Afdrukken inschakelen",
|
||||
"description": "Lokale printers doorverwijzen naar de externe sessie"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Schakel schijfomleiding in",
|
||||
"description": "Een lokale map toewijzen als schijfstation in de externe sessie."
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Kopiëren uitschakelen",
|
||||
"description": "Voorkom het kopiëren van tekst vanuit de externe sessie."
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Plakken uitschakelen",
|
||||
"description": "Voorkom dat tekst in de externe sessie wordt geplakt."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "guacd URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Skriftnavn",
|
||||
"fontSize": "Skriftstørrelse",
|
||||
"colorScheme": "Fargeskjema",
|
||||
"backspaceKey": "Tilbaketast"
|
||||
}
|
||||
"backspaceKey": "Tilbaketast",
|
||||
"colorDepth": "Fargedybde",
|
||||
"resizeMethod": "Endre størrelse-metoden"
|
||||
},
|
||||
"remoteDesktop": "Eksternt skrivebord",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Virtuelt nettverk",
|
||||
"unencryptedShell": "Ukryptert skall"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Eksternt skrivebord",
|
||||
"feature_desktop_desc": "Åpne RDP-, VNC- og Telnet-økter i en fane, på samme måte som SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Fargedybde"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Endre størrelse-metoden"
|
||||
},
|
||||
"resizeDisplayUpdate": "Oppdatering av skjerm",
|
||||
"resizeReconnect": "Koble til igjen",
|
||||
"inherit": "Arve",
|
||||
"on": "På",
|
||||
"off": "Av",
|
||||
"forceLossless": {
|
||||
"label": "Tving tapsfri",
|
||||
"description": "Tving frem tapsfri bildekoding (høyere kvalitet, mer båndbredde)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Bakgrunn",
|
||||
"description": "Vis skrivebordsbakgrunn (deaktivering forbedrer ytelsen)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Skriftutjevning",
|
||||
"description": "Aktiver gjengivelse av ClearType-skrifttyper"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Skrivebordskomposisjon",
|
||||
"description": "Aktiver Aero Glass-effekter"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Deaktiver lyd",
|
||||
"description": "Demp all lyd fra den eksterne økten"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Aktiver utskrift",
|
||||
"description": "Omdiriger lokale skrivere til den eksterne økten"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Aktiver omdirigering av stasjoner",
|
||||
"description": "Tilordne en lokal mappe som en stasjon i den eksterne økten"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Deaktiver kopiering",
|
||||
"description": "Forhindre kopiering av tekst fra den eksterne økten"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Deaktiver liming",
|
||||
"description": "Forhindre innliming av tekst i den eksterne økten"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "guacd-URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Nazwa czcionki",
|
||||
"fontSize": "Rozmiar czcionki",
|
||||
"colorScheme": "Schemat kolorów",
|
||||
"backspaceKey": "Klawisz Backspace"
|
||||
}
|
||||
"backspaceKey": "Klawisz Backspace",
|
||||
"colorDepth": "Głębia koloru",
|
||||
"resizeMethod": "Metoda zmiany rozmiaru"
|
||||
},
|
||||
"remoteDesktop": "Pulpit zdalny",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Sieć wirtualna",
|
||||
"unencryptedShell": "Niezaszyfrowana powłoka"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Zdalny pulpit",
|
||||
"feature_desktop_desc": "Otwieraj sesje RDP, VNC i Telnet w karcie, tak samo jak SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Głębia koloru"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Metoda zmiany rozmiaru"
|
||||
},
|
||||
"resizeDisplayUpdate": "Aktualizacja ekranu",
|
||||
"resizeReconnect": "Połącz ponownie",
|
||||
"inherit": "Dziedzicz",
|
||||
"on": "Wł.",
|
||||
"off": "Wył.",
|
||||
"forceLossless": {
|
||||
"label": "Wymuś bezstratność",
|
||||
"description": "Wymuś bezstratne kodowanie obrazu (wyższa jakość, większa przepustowość)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Tapeta",
|
||||
"description": "Pokaż tapetę pulpitu (wyłączenie poprawia wydajność)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Wygładzanie czcionek",
|
||||
"description": "Włącz renderowanie czcionek ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Kompozycja pulpitu",
|
||||
"description": "Włącz efekty Aero Glass"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Wyłącz dźwięk",
|
||||
"description": "Wycisz cały dźwięk z sesji zdalnej"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Włącz drukowanie",
|
||||
"description": "Przekieruj drukarki lokalne do sesji zdalnej"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Włącz przekierowanie dysku",
|
||||
"description": "Mapuj folder lokalny jako dysk w sesji zdalnej"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Wyłącz kopiowanie",
|
||||
"description": "Zapobiegaj kopiowaniu tekstu z sesji zdalnej"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Wyłącz wklejanie",
|
||||
"description": "Zapobiegaj wklejaniu tekstu do sesji zdalnej"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "Adres URL guacd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Nome da fonte",
|
||||
"fontSize": "Tamanho da fonte",
|
||||
"colorScheme": "Esquema de cores",
|
||||
"backspaceKey": "Tecla Backspace"
|
||||
}
|
||||
"backspaceKey": "Tecla Backspace",
|
||||
"colorDepth": "Profundidade de cor",
|
||||
"resizeMethod": "Método de redimensionamento"
|
||||
},
|
||||
"remoteDesktop": "Área de Trabalho Remota",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Rede Virtual",
|
||||
"unencryptedShell": "Shell não criptografado"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Área de trabalho remota",
|
||||
"feature_desktop_desc": "Abra sessões RDP, VNC e Telnet em uma aba, da mesma forma que o SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Profundidade de cor"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Método de redimensionamento"
|
||||
},
|
||||
"resizeDisplayUpdate": "Atualização de exibição",
|
||||
"resizeReconnect": "Reconectar",
|
||||
"inherit": "Herdar",
|
||||
"on": "Sobre",
|
||||
"off": "Desligado",
|
||||
"forceLossless": {
|
||||
"label": "Força sem perdas",
|
||||
"description": "Forçar codificação de imagem sem perdas (maior qualidade, mais largura de banda)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Papel de parede",
|
||||
"description": "Exibir papel de parede da área de trabalho (desativar melhora o desempenho)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Suavização de fontes",
|
||||
"description": "Ativar renderização de fontes ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Composição de mesa",
|
||||
"description": "Ative os efeitos de vidro Aero"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Desativar áudio",
|
||||
"description": "Silenciar todo o áudio da sessão remota."
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Habilitar impressão",
|
||||
"description": "Redirecionar impressoras locais para a sessão remota."
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Ativar o redirecionamento de unidades",
|
||||
"description": "Mapear uma pasta local como uma unidade na sessão remota."
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Desativar cópia",
|
||||
"description": "Impedir a cópia de texto da sessão remota"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Desativar colagem",
|
||||
"description": "Impedir a colagem de texto na sessão remota."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "URL do guacd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Nome da Fonte",
|
||||
"fontSize": "Tamanho da Fonte",
|
||||
"colorScheme": "Esquema de Cores",
|
||||
"backspaceKey": "Tecla Backspace"
|
||||
}
|
||||
"backspaceKey": "Tecla Backspace",
|
||||
"colorDepth": "Profundidade de cor",
|
||||
"resizeMethod": "Método de redimensionamento"
|
||||
},
|
||||
"remoteDesktop": "Ambiente de trabalho remoto",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Rede Virtual",
|
||||
"unencryptedShell": "Shell não encriptado"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Área de trabalho remota",
|
||||
"feature_desktop_desc": "Abra as sessões RDP, VNC e Telnet num separador, da mesma forma que o SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Profundidade de cor"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Método de redimensionamento"
|
||||
},
|
||||
"resizeDisplayUpdate": "Atualização de visualização",
|
||||
"resizeReconnect": "Reconectar",
|
||||
"inherit": "Herdar",
|
||||
"on": "Em",
|
||||
"off": "Desligado",
|
||||
"forceLossless": {
|
||||
"label": "Forçar sem perdas",
|
||||
"description": "Forçar codificação de imagem sem perdas (maior qualidade, mais largura de banda)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Fundo de ecrã",
|
||||
"description": "Mostrar fundo de ecrã (desativar melhora o desempenho)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Suavização de Fontes",
|
||||
"description": "Ativar renderização de fontes ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Composição do ambiente de trabalho",
|
||||
"description": "Ativar efeitos de transparência Aero"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Desativar Áudio",
|
||||
"description": "Silenciar todo o áudio da sessão remota"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Ativar Impressão",
|
||||
"description": "Redirecionar impressoras locais para a sessão remota"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Ativar Redirecionamento de Unidades",
|
||||
"description": "Mapear uma pasta local como unidade na sessão remota"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Desativar Cópia",
|
||||
"description": "Impedir a cópia de texto da sessão remota"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Desativar Colagem",
|
||||
"description": "Impedir a colagem de texto na sessão remota"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "URL do guacd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Nume font",
|
||||
"fontSize": "Dimensiunea fontului",
|
||||
"colorScheme": "Schemă de culori",
|
||||
"backspaceKey": "Tasta Backspace"
|
||||
}
|
||||
"backspaceKey": "Tasta Backspace",
|
||||
"colorDepth": "Adâncimea culorii",
|
||||
"resizeMethod": "Metodă de redimensionare"
|
||||
},
|
||||
"remoteDesktop": "Desktop la distanță",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Rețea virtuală",
|
||||
"unencryptedShell": "Shell necriptat"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Desktop la distanță",
|
||||
"feature_desktop_desc": "Deschideți sesiuni RDP, VNC și Telnet într-o filă, la fel ca în cazul SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Adâncimea culorii"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Metodă de redimensionare"
|
||||
},
|
||||
"resizeDisplayUpdate": "Actualizare afișare",
|
||||
"resizeReconnect": "Reconectați-vă",
|
||||
"inherit": "Moşteni",
|
||||
"on": "Pe",
|
||||
"off": "Dezactivat",
|
||||
"forceLossless": {
|
||||
"label": "Forța fără pierderi",
|
||||
"description": "Forțează codificarea imaginilor fără pierderi (calitate mai mare, lățime de bandă mai mare)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Tapet",
|
||||
"description": "Afișează imaginea de fundal a desktopului (dezactivarea îmbunătățește performanța)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Netezirea fonturilor",
|
||||
"description": "Activează randarea fonturilor ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Compoziție desktop",
|
||||
"description": "Activează efectele Aero Glass"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Dezactivați sunetul",
|
||||
"description": "Dezactivați tot sunetul din sesiunea la distanță"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Activează imprimarea",
|
||||
"description": "Redirecționează imprimantele locale către sesiunea la distanță"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Activați redirecționarea unității",
|
||||
"description": "Maparea unui folder local ca unitate în sesiunea la distanță"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Dezactivați copierea",
|
||||
"description": "Împiedicați copierea textului din sesiunea la distanță"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Dezactivați lipirea",
|
||||
"description": "Împiedicați lipirea textului în sesiunea la distanță"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "URL-ul guacd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Название шрифта",
|
||||
"fontSize": "Размер шрифта",
|
||||
"colorScheme": "Цветовая схема",
|
||||
"backspaceKey": "Клавиша Backspace"
|
||||
}
|
||||
"backspaceKey": "Клавиша Backspace",
|
||||
"colorDepth": "Глубина цвета",
|
||||
"resizeMethod": "Метод изменения размера"
|
||||
},
|
||||
"remoteDesktop": "Удаленный рабочий стол",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Виртуальная сеть",
|
||||
"unencryptedShell": "Незашифрованная оболочка"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Удаленный рабочий стол",
|
||||
"feature_desktop_desc": "Открывайте сеансы RDP, VNC и Telnet во вкладке, как и SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Глубина цвета"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Метод изменения размера"
|
||||
},
|
||||
"resizeDisplayUpdate": "Обновление дисплея",
|
||||
"resizeReconnect": "Переподключитесь",
|
||||
"inherit": "Наследовать",
|
||||
"on": "На",
|
||||
"off": "Выключенный",
|
||||
"forceLossless": {
|
||||
"label": "Принудительно без потерь",
|
||||
"description": "Принудительное кодирование изображений без потерь (более высокое качество, больший расход трафика)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Обои",
|
||||
"description": "Показывать обои рабочего стола (отключение повышает производительность)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Сглаживание шрифтов",
|
||||
"description": "Включить рендеринг шрифтов ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Композиция рабочего стола",
|
||||
"description": "Включить эффекты Aero Glass"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Отключить звук",
|
||||
"description": "Отключить весь звук в удаленном сеансе"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Включить печать",
|
||||
"description": "Перенаправлять локальные принтеры в удаленный сеанс"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Включить перенаправление дисков",
|
||||
"description": "Подключать локальную папку как диск в удаленном сеансе"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Отключить копирование",
|
||||
"description": "Запретить копирование текста из удаленного сеанса"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Отключить вставку",
|
||||
"description": "Запретить вставку текста в удаленный сеанс"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "URL guacd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Назив фонта",
|
||||
"fontSize": "Величина фонта",
|
||||
"colorScheme": "Шема боја",
|
||||
"backspaceKey": "Тастер Backspace"
|
||||
}
|
||||
"backspaceKey": "Тастер Backspace",
|
||||
"colorDepth": "Дубина боје",
|
||||
"resizeMethod": "Метод промене величине"
|
||||
},
|
||||
"remoteDesktop": "Удаљена радна површина",
|
||||
"tabRdp": "РДП",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Телнет",
|
||||
"virtualNetwork": "Виртуелна мрежа",
|
||||
"unencryptedShell": "Нешифрована љуска"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Удаљена радна површина",
|
||||
"feature_desktop_desc": "Отворите RDP, VNC и Telnet сесије у картици, исто као и SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Дубина боје"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Метод промене величине"
|
||||
},
|
||||
"resizeDisplayUpdate": "Ажурирање екрана",
|
||||
"resizeReconnect": "Поново се повежите",
|
||||
"inherit": "Наследи",
|
||||
"on": "Укључено",
|
||||
"off": "Искључено",
|
||||
"forceLossless": {
|
||||
"label": "Присилно без губитака",
|
||||
"description": "Присилно кодирање слике без губитака (виши квалитет, већи пропусни опсег)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Позадина",
|
||||
"description": "Прикажи позадину радне површине (онемогућавање побољшава перформансе)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Изглађивање фонта",
|
||||
"description": "Омогући приказивање ClearType фонта"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Композиција на радној површини",
|
||||
"description": "Омогући ефекте Aero Glass-а"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Онемогући звук",
|
||||
"description": "Искључите сав звук из удаљене сесије"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Омогући штампање",
|
||||
"description": "Преусмерите локалне штампаче на удаљену сесију"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Омогући преусмеравање диска",
|
||||
"description": "Мапирајте локалну фасциклу као диск у удаљеној сесији"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Онемогући копирање",
|
||||
"description": "Спречите копирање текста из удаљене сесије"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Онемогући лепљење",
|
||||
"description": "Спречите лепљење текста у удаљену сесију"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "URL адреса guacd-а"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Typsnittsnamn",
|
||||
"fontSize": "Fontstorlek",
|
||||
"colorScheme": "Färgschema",
|
||||
"backspaceKey": "Backstegstangenten"
|
||||
}
|
||||
"backspaceKey": "Backstegstangenten",
|
||||
"colorDepth": "Färgdjup",
|
||||
"resizeMethod": "Ändra storleksmetod"
|
||||
},
|
||||
"remoteDesktop": "Fjärrskrivbord",
|
||||
"tabRdp": "Landsbygdsutveckling",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Virtuellt nätverk",
|
||||
"unencryptedShell": "Okrypterat skal"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Fjärrskrivbord",
|
||||
"feature_desktop_desc": "Öppna RDP-, VNC- och Telnet-sessioner i en flik, samma som med SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Färgdjup"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Ändra storleksmetod"
|
||||
},
|
||||
"resizeDisplayUpdate": "Uppdatering av displayen",
|
||||
"resizeReconnect": "Återanslut",
|
||||
"inherit": "Ärva",
|
||||
"on": "På",
|
||||
"off": "Av",
|
||||
"forceLossless": {
|
||||
"label": "Tvinga förlustfri",
|
||||
"description": "Tvinga fram förlustfri bildkodning (högre kvalitet, mer bandbredd)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Tapet",
|
||||
"description": "Visa skrivbordsbakgrund (inaktivering förbättrar prestandan)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Utjämning av teckensnitt",
|
||||
"description": "Aktivera rendering av ClearType-teckensnitt"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Skrivbordskomposition",
|
||||
"description": "Aktivera Aero Glass-effekter"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Inaktivera ljud",
|
||||
"description": "Stäng av allt ljud från fjärrsessionen"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Aktivera utskrift",
|
||||
"description": "Omdirigera lokala skrivare till fjärrsessionen"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Aktivera omdirigering av kördata",
|
||||
"description": "Mappa en lokal mapp som en enhet i fjärrsessionen"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Inaktivera kopiering",
|
||||
"description": "Förhindra kopiering av text från fjärrsessionen"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Inaktivera inklistring",
|
||||
"description": "Förhindra att text klistras in i fjärrsessionen"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "guacd-URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "ชื่อแบบอักษร",
|
||||
"fontSize": "ขนาดตัวอักษร",
|
||||
"colorScheme": "โทนสี",
|
||||
"backspaceKey": "ปุ่มลบ"
|
||||
}
|
||||
"backspaceKey": "ปุ่มลบ",
|
||||
"colorDepth": "ความลึกของสี",
|
||||
"resizeMethod": "วิธีการปรับขนาด"
|
||||
},
|
||||
"remoteDesktop": "รีโมทเดสก์ท็อป",
|
||||
"tabRdp": "อาร์ดีพี",
|
||||
"tabVnc": "วีเอ็นซี",
|
||||
"tabTelnet": "เทลเน็ต",
|
||||
"virtualNetwork": "เครือข่ายเสมือน",
|
||||
"unencryptedShell": "เชลล์ที่ไม่ได้เข้ารหัส"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "การเข้าถึงเดสก์ท็อประยะไกล",
|
||||
"feature_desktop_desc": "เปิดเซสชัน RDP, VNC และ Telnet ในแท็บเดียวกัน เหมือนกับการเปิด SSH"
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "ความลึกของสี"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "วิธีการปรับขนาด"
|
||||
},
|
||||
"resizeDisplayUpdate": "อัปเดตการแสดงผล",
|
||||
"resizeReconnect": "เชื่อมต่อใหม่",
|
||||
"inherit": "สืบทอด",
|
||||
"on": "บน",
|
||||
"off": "ปิด",
|
||||
"forceLossless": {
|
||||
"label": "บังคับการสูญเสีย",
|
||||
"description": "บังคับใช้การเข้ารหัสภาพแบบไม่สูญเสียคุณภาพ (คุณภาพสูงกว่า ใช้แบนด์วิดท์มากกว่า)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "วอลเปเปอร์",
|
||||
"description": "แสดงภาพพื้นหลังเดสก์ท็อป (การปิดใช้งานจะช่วยเพิ่มประสิทธิภาพ)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "การปรับความเรียบของแบบอักษร",
|
||||
"description": "เปิดใช้งานการแสดงผลฟอนต์ ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "การจัดองค์ประกอบเดสก์ท็อป",
|
||||
"description": "เปิดใช้งานเอฟเฟกต์กระจก Aero"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "ปิดใช้งานเสียง",
|
||||
"description": "ปิดเสียงทั้งหมดจากการเชื่อมต่อระยะไกล"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "เปิดใช้งานการพิมพ์",
|
||||
"description": "โอนย้ายเครื่องพิมพ์ในเครื่องไปยังเซสชันระยะไกล"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "เปิดใช้งานการเปลี่ยนเส้นทางไดรฟ์",
|
||||
"description": "แมปโฟลเดอร์ในเครื่องเป็นไดรฟ์ในเซสชันระยะไกล"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "ปิดใช้งานการคัดลอก",
|
||||
"description": "ป้องกันการคัดลอกข้อความจากเซสชันระยะไกล"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "ปิดใช้งานการวาง",
|
||||
"description": "ป้องกันการวางข้อความลงในเซสชันระยะไกล"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "URL guacd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Yazı Tipi Adı",
|
||||
"fontSize": "Yazı Tipi Boyutu",
|
||||
"colorScheme": "Renk Şeması",
|
||||
"backspaceKey": "Geri Silme Tuşu"
|
||||
}
|
||||
"backspaceKey": "Geri Silme Tuşu",
|
||||
"colorDepth": "Renk Derinliği",
|
||||
"resizeMethod": "Yeniden Boyutlandırma Yöntemi"
|
||||
},
|
||||
"remoteDesktop": "Uzak Masaüstü",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Sanal Ağ",
|
||||
"unencryptedShell": "Şifrelenmemiş Shell"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Uzaktan masaüstü",
|
||||
"feature_desktop_desc": "SSH'de olduğu gibi, RDP, VNC ve Telnet oturumlarını da bir sekmede açın."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Renk Derinliği"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Yeniden Boyutlandırma Yöntemi"
|
||||
},
|
||||
"resizeDisplayUpdate": "Ekran Güncellemesi",
|
||||
"resizeReconnect": "Yeniden bağlan",
|
||||
"inherit": "Miras almak",
|
||||
"on": "Açık",
|
||||
"off": "Kapalı",
|
||||
"forceLossless": {
|
||||
"label": "Kayıpsız Zorla",
|
||||
"description": "Kayıpsız görüntü kodlamasını zorla (daha yüksek kalite, daha fazla bant genişliği)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Duvar Kağıdı",
|
||||
"description": "Masaüstü duvar kağıdını göster (devre dışı bırakmak performansı artırır)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Yazı Tipi Yumuşatma",
|
||||
"description": "ClearType yazı tipi işlemeyi etkinleştir"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Masaüstü Birleştirme",
|
||||
"description": "Aero cam efektlerini etkinleştir"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Sesi Devre Dışı Bırak",
|
||||
"description": "Uzak oturumdaki tüm sesleri kapat"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Yazdırmayı Etkinleştir",
|
||||
"description": "Yerel yazıcıları uzak oturuma yönlendir"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Sürücü Yönlendirmesini Etkinleştir",
|
||||
"description": "Yerel bir klasörü uzak oturumda sürücü olarak eşleştir"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Kopyalamayı Devre Dışı Bırak",
|
||||
"description": "Uzak oturumdan metin kopyalamayı engelle"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Yapıştırmayı Devre Dışı Bırak",
|
||||
"description": "Uzak oturuma metin yapıştırmayı engelle"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "guacd URL'si"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Назва шрифту",
|
||||
"fontSize": "Розмір шрифту",
|
||||
"colorScheme": "Колірна схема",
|
||||
"backspaceKey": "Клавіша Backspace"
|
||||
}
|
||||
"backspaceKey": "Клавіша Backspace",
|
||||
"colorDepth": "Глибина кольору",
|
||||
"resizeMethod": "Метод зміни розміру"
|
||||
},
|
||||
"remoteDesktop": "Віддалений робочий стіл",
|
||||
"tabRdp": "ПРП",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Телнет",
|
||||
"virtualNetwork": "Віртуальна мережа",
|
||||
"unencryptedShell": "Незашифрована оболонка"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Віддалений робочий стіл",
|
||||
"feature_desktop_desc": "Відкривати сеанси RDP, VNC та Telnet у вкладці, так само, як SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Глибина кольору"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Метод зміни розміру"
|
||||
},
|
||||
"resizeDisplayUpdate": "Оновлення дисплея",
|
||||
"resizeReconnect": "Знову підключитися",
|
||||
"inherit": "Успадкувати",
|
||||
"on": "Увімкнено",
|
||||
"off": "Вимкнено",
|
||||
"forceLossless": {
|
||||
"label": "Примусово без втрат",
|
||||
"description": "Примусове кодування зображень без втрат (вища якість, більша пропускна здатність)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Шпалери",
|
||||
"description": "Показувати шпалери робочого столу (вимикання покращує продуктивність)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Згладжування шрифтів",
|
||||
"description": "Увімкнути рендеринг шрифтів ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Композиція робочого столу",
|
||||
"description": "Увімкнути ефекти Aero Glass"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Вимкнути аудіо",
|
||||
"description": "Вимкнути весь звук під час віддаленого сеансу"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Увімкнути друк",
|
||||
"description": "Перенаправити локальні принтери на віддалений сеанс"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Увімкнути перенаправлення диска",
|
||||
"description": "Призначення локальної папки як диска у віддаленому сеансі"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Вимкнути копіювання",
|
||||
"description": "Запобігти копіюванню тексту з віддаленого сеансу"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Вимкнути вставку",
|
||||
"description": "Запобігти вставці тексту у віддалений сеанс"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "URL-адреса GUACD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "Tên phông chữ",
|
||||
"fontSize": "Cỡ chữ",
|
||||
"colorScheme": "Bảng màu",
|
||||
"backspaceKey": "Phím Backspace"
|
||||
}
|
||||
"backspaceKey": "Phím Backspace",
|
||||
"colorDepth": "Độ sâu màu",
|
||||
"resizeMethod": "Phương thức thay đổi kích thước"
|
||||
},
|
||||
"remoteDesktop": "Máy tính từ xa",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "Mạng ảo",
|
||||
"unencryptedShell": "Shell không mã hóa"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "Máy tính từ xa",
|
||||
"feature_desktop_desc": "Mở các phiên RDP, VNC và Telnet trong cùng một tab, tương tự như SSH."
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "Độ sâu màu"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "Phương thức thay đổi kích thước"
|
||||
},
|
||||
"resizeDisplayUpdate": "Cập nhật hiển thị",
|
||||
"resizeReconnect": "Kết nối lại",
|
||||
"inherit": "Kế thừa",
|
||||
"on": "TRÊN",
|
||||
"off": "Tắt",
|
||||
"forceLossless": {
|
||||
"label": "Ép buộc Lossless",
|
||||
"description": "Ép buộc mã hóa ảnh không mất dữ liệu (chất lượng cao hơn, tốn băng thông)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "Hình nền",
|
||||
"description": "Hiển thị hình nền desktop (tắt để cải thiện hiệu suất)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "Làm mịn phông chữ",
|
||||
"description": "Bật kết xuất phông chữ ClearType"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "Tổ hợp màn hình nền",
|
||||
"description": "Bật hiệu ứng kính Aero"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "Tắt âm thanh",
|
||||
"description": "Tắt tất cả âm thanh từ phiên từ xa"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "Bật in",
|
||||
"description": "Chuyển hướng máy in cục bộ đến phiên từ xa"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "Bật chuyển hướng ổ đĩa",
|
||||
"description": "Ánh xạ thư mục cục bộ thành ổ đĩa trong phiên từ xa"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "Tắt sao chép",
|
||||
"description": "Ngăn sao chép văn bản từ phiên từ xa"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "Tắt dán",
|
||||
"description": "Ngăn dán văn bản vào phiên từ xa"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "URL guacd"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "字体名称",
|
||||
"fontSize": "字体大小",
|
||||
"colorScheme": "配色方案",
|
||||
"backspaceKey": "退格键"
|
||||
}
|
||||
"backspaceKey": "退格键",
|
||||
"colorDepth": "颜色深度",
|
||||
"resizeMethod": "调整大小方法"
|
||||
},
|
||||
"remoteDesktop": "远程桌面",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "虚拟网络",
|
||||
"unencryptedShell": "未加密 Shell"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "远程桌面",
|
||||
"feature_desktop_desc": "像 SSH 一样,在标签页中直接打开 RDP、VNC 和 Telnet 会话。"
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "颜色深度"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "调整大小方法"
|
||||
},
|
||||
"resizeDisplayUpdate": "显示更新",
|
||||
"resizeReconnect": "重新连接",
|
||||
"inherit": "继承",
|
||||
"on": "开启",
|
||||
"off": "关闭",
|
||||
"forceLossless": {
|
||||
"label": "强制无损",
|
||||
"description": "强制无损图像编码(质量更高,占用更多带宽)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "壁纸",
|
||||
"description": "显示桌面壁纸(禁用可提高性能)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "字体平滑",
|
||||
"description": "启用 ClearType 字体渲染"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "桌面合成",
|
||||
"description": "启用 Aero 玻璃效果"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "禁用音频",
|
||||
"description": "将远程会话的所有音频静音"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "启用打印",
|
||||
"description": "将本地打印机重定向到远程会话"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "启用驱动器重定向",
|
||||
"description": "将本地文件夹映射为远程会话中的驱动器"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "禁用复制",
|
||||
"description": "禁止从远程会话复制文本"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "禁用粘贴",
|
||||
"description": "禁止粘贴文本到远程会话"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "guacd URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +204,75 @@
|
||||
"fontName": "字型名稱",
|
||||
"fontSize": "字型大小",
|
||||
"colorScheme": "色彩配置",
|
||||
"backspaceKey": "退格鍵"
|
||||
}
|
||||
"backspaceKey": "退格鍵",
|
||||
"colorDepth": "色彩深度",
|
||||
"resizeMethod": "調整尺寸方法"
|
||||
},
|
||||
"remoteDesktop": "遠端桌面",
|
||||
"tabRdp": "RDP",
|
||||
"tabVnc": "VNC",
|
||||
"tabTelnet": "Telnet",
|
||||
"virtualNetwork": "虛擬網路",
|
||||
"unencryptedShell": "未加密殼層"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_desktop": "遠端桌面",
|
||||
"feature_desktop_desc": "像 SSH 一樣,在同一個標籤頁中開啟 RDP、VNC 和 Telnet 會話。"
|
||||
},
|
||||
"settings": {
|
||||
"user": {
|
||||
"colorDepth": {
|
||||
"label": "色彩深度"
|
||||
},
|
||||
"resizeMethod": {
|
||||
"label": "調整尺寸方法"
|
||||
},
|
||||
"resizeDisplayUpdate": "顯示更新",
|
||||
"resizeReconnect": "重新連接",
|
||||
"inherit": "繼承",
|
||||
"on": "在",
|
||||
"off": "離開",
|
||||
"forceLossless": {
|
||||
"label": "強制無損",
|
||||
"description": "強制使用無損影像編碼(品質較高,頻寬需求較大)"
|
||||
},
|
||||
"enableWallpaper": {
|
||||
"label": "桌面背景",
|
||||
"description": "顯示桌面背景(停用可提升效能)"
|
||||
},
|
||||
"enableFontSmoothing": {
|
||||
"label": "字型平滑處理",
|
||||
"description": "啟用 ClearType 字型轉譯"
|
||||
},
|
||||
"enableDesktopComposition": {
|
||||
"label": "桌面合成",
|
||||
"description": "啟用 Aero 玻璃效果"
|
||||
},
|
||||
"disableAudio": {
|
||||
"label": "停用音訊",
|
||||
"description": "將遠端工作階段的音訊全部靜音"
|
||||
},
|
||||
"enablePrinting": {
|
||||
"label": "啟用列印功能",
|
||||
"description": "將本機印表機重新導向至遠端工作階段"
|
||||
},
|
||||
"enableDrive": {
|
||||
"label": "啟用磁碟機重新導向",
|
||||
"description": "在遠端工作階段中將本機資料夾對應為磁碟機"
|
||||
},
|
||||
"disableCopy": {
|
||||
"label": "停用複製",
|
||||
"description": "防止從遠端工作階段複製文字"
|
||||
},
|
||||
"disablePaste": {
|
||||
"label": "停用貼上",
|
||||
"description": "防止將文字貼上至遠端工作階段"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"guacdUrl": {
|
||||
"label": "guacd URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,15 +16,33 @@
|
||||
},
|
||||
"capabilities": [
|
||||
"hosts:read",
|
||||
"credentials:read",
|
||||
"credentials:use",
|
||||
"ssh:connect",
|
||||
"network:serve",
|
||||
"audit:read",
|
||||
"desktop:window",
|
||||
"ui:surface"
|
||||
],
|
||||
"optionalDependencies": {
|
||||
"session-recording": "^1.0.0",
|
||||
"tunnels": "^1.0.0"
|
||||
},
|
||||
"provides": [
|
||||
{
|
||||
"service": "sessions.live",
|
||||
"version": "1.0.0",
|
||||
"permission": "remote-desktop.sessions",
|
||||
"names": ["rdp", "vnc", "telnet"]
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
{
|
||||
"service": "recordings.writer",
|
||||
"versionRange": "^1.1.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"service": "tunnels.access",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
}
|
||||
@@ -50,30 +68,369 @@
|
||||
"openFrom": ["host-context-menu", "palette"]
|
||||
}
|
||||
],
|
||||
"hostCapability": [
|
||||
{
|
||||
"key": "enableRdp",
|
||||
"labelKey": "hosts.tabRdp",
|
||||
"editorTab": "rdp"
|
||||
},
|
||||
{
|
||||
"key": "enableVnc",
|
||||
"labelKey": "hosts.tabVnc",
|
||||
"editorTab": "vnc"
|
||||
},
|
||||
{
|
||||
"key": "enableTelnet",
|
||||
"labelKey": "hosts.tabTelnet",
|
||||
"editorTab": "telnet"
|
||||
}
|
||||
],
|
||||
"actionSlots": [
|
||||
{
|
||||
"id": "remote-desktop.toolbar",
|
||||
"accepts": ["button"]
|
||||
}
|
||||
],
|
||||
"guest": true
|
||||
"guest": true,
|
||||
"permissions": [
|
||||
{
|
||||
"name": "sessions",
|
||||
"titleKey": "permissions.sessions.title",
|
||||
"descriptionKey": "permissions.sessions.description",
|
||||
"defaultRoles": ["admin", "user"]
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"admin": [
|
||||
{
|
||||
"key": "enabled",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.admin.enabled.label",
|
||||
"descriptionKey": "settings.admin.enabled.description",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"key": "guacdUrl",
|
||||
"type": "string",
|
||||
"labelKey": "settings.admin.guacdUrl.label",
|
||||
"descriptionKey": "settings.admin.guacdUrl.description",
|
||||
"placeholderKey": "settings.admin.guacdUrl.placeholder",
|
||||
"requires": "enabled",
|
||||
"default": ""
|
||||
}
|
||||
],
|
||||
"user": [
|
||||
{
|
||||
"key": "colorDepth",
|
||||
"type": "select",
|
||||
"labelKey": "settings.user.colorDepth.label",
|
||||
"descriptionKey": "settings.user.colorDepth.description",
|
||||
"group": "settings.user.group",
|
||||
"options": [
|
||||
{
|
||||
"value": "inherit",
|
||||
"labelKey": "settings.user.inherit"
|
||||
},
|
||||
{
|
||||
"value": "16",
|
||||
"labelKey": "settings.user.colorDepth16"
|
||||
},
|
||||
{
|
||||
"value": "24",
|
||||
"labelKey": "settings.user.colorDepth24"
|
||||
},
|
||||
{
|
||||
"value": "32",
|
||||
"labelKey": "settings.user.colorDepth32"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
},
|
||||
{
|
||||
"key": "resizeMethod",
|
||||
"type": "select",
|
||||
"labelKey": "settings.user.resizeMethod.label",
|
||||
"descriptionKey": "settings.user.resizeMethod.description",
|
||||
"group": "settings.user.group",
|
||||
"options": [
|
||||
{
|
||||
"value": "inherit",
|
||||
"labelKey": "settings.user.inherit"
|
||||
},
|
||||
{
|
||||
"value": "display-update",
|
||||
"labelKey": "settings.user.resizeDisplayUpdate"
|
||||
},
|
||||
{
|
||||
"value": "reconnect",
|
||||
"labelKey": "settings.user.resizeReconnect"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
},
|
||||
{
|
||||
"key": "forceLossless",
|
||||
"type": "select",
|
||||
"labelKey": "settings.user.forceLossless.label",
|
||||
"descriptionKey": "settings.user.forceLossless.description",
|
||||
"group": "settings.user.group",
|
||||
"options": [
|
||||
{
|
||||
"value": "inherit",
|
||||
"labelKey": "settings.user.inherit"
|
||||
},
|
||||
{
|
||||
"value": "on",
|
||||
"labelKey": "settings.user.on"
|
||||
},
|
||||
{
|
||||
"value": "off",
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
},
|
||||
{
|
||||
"key": "enableWallpaper",
|
||||
"type": "select",
|
||||
"labelKey": "settings.user.enableWallpaper.label",
|
||||
"descriptionKey": "settings.user.enableWallpaper.description",
|
||||
"group": "settings.user.group",
|
||||
"options": [
|
||||
{
|
||||
"value": "inherit",
|
||||
"labelKey": "settings.user.inherit"
|
||||
},
|
||||
{
|
||||
"value": "on",
|
||||
"labelKey": "settings.user.on"
|
||||
},
|
||||
{
|
||||
"value": "off",
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
},
|
||||
{
|
||||
"key": "enableFontSmoothing",
|
||||
"type": "select",
|
||||
"labelKey": "settings.user.enableFontSmoothing.label",
|
||||
"descriptionKey": "settings.user.enableFontSmoothing.description",
|
||||
"group": "settings.user.group",
|
||||
"options": [
|
||||
{
|
||||
"value": "inherit",
|
||||
"labelKey": "settings.user.inherit"
|
||||
},
|
||||
{
|
||||
"value": "on",
|
||||
"labelKey": "settings.user.on"
|
||||
},
|
||||
{
|
||||
"value": "off",
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
},
|
||||
{
|
||||
"key": "enableDesktopComposition",
|
||||
"type": "select",
|
||||
"labelKey": "settings.user.enableDesktopComposition.label",
|
||||
"descriptionKey": "settings.user.enableDesktopComposition.description",
|
||||
"group": "settings.user.group",
|
||||
"options": [
|
||||
{
|
||||
"value": "inherit",
|
||||
"labelKey": "settings.user.inherit"
|
||||
},
|
||||
{
|
||||
"value": "on",
|
||||
"labelKey": "settings.user.on"
|
||||
},
|
||||
{
|
||||
"value": "off",
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
},
|
||||
{
|
||||
"key": "disableAudio",
|
||||
"type": "select",
|
||||
"labelKey": "settings.user.disableAudio.label",
|
||||
"descriptionKey": "settings.user.disableAudio.description",
|
||||
"group": "settings.user.group",
|
||||
"options": [
|
||||
{
|
||||
"value": "inherit",
|
||||
"labelKey": "settings.user.inherit"
|
||||
},
|
||||
{
|
||||
"value": "on",
|
||||
"labelKey": "settings.user.on"
|
||||
},
|
||||
{
|
||||
"value": "off",
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
},
|
||||
{
|
||||
"key": "enablePrinting",
|
||||
"type": "select",
|
||||
"labelKey": "settings.user.enablePrinting.label",
|
||||
"descriptionKey": "settings.user.enablePrinting.description",
|
||||
"group": "settings.user.group",
|
||||
"options": [
|
||||
{
|
||||
"value": "inherit",
|
||||
"labelKey": "settings.user.inherit"
|
||||
},
|
||||
{
|
||||
"value": "on",
|
||||
"labelKey": "settings.user.on"
|
||||
},
|
||||
{
|
||||
"value": "off",
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
},
|
||||
{
|
||||
"key": "enableDrive",
|
||||
"type": "select",
|
||||
"labelKey": "settings.user.enableDrive.label",
|
||||
"descriptionKey": "settings.user.enableDrive.description",
|
||||
"group": "settings.user.group",
|
||||
"options": [
|
||||
{
|
||||
"value": "inherit",
|
||||
"labelKey": "settings.user.inherit"
|
||||
},
|
||||
{
|
||||
"value": "on",
|
||||
"labelKey": "settings.user.on"
|
||||
},
|
||||
{
|
||||
"value": "off",
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
},
|
||||
{
|
||||
"key": "disableCopy",
|
||||
"type": "select",
|
||||
"labelKey": "settings.user.disableCopy.label",
|
||||
"descriptionKey": "settings.user.disableCopy.description",
|
||||
"group": "settings.user.group",
|
||||
"options": [
|
||||
{
|
||||
"value": "inherit",
|
||||
"labelKey": "settings.user.inherit"
|
||||
},
|
||||
{
|
||||
"value": "on",
|
||||
"labelKey": "settings.user.on"
|
||||
},
|
||||
{
|
||||
"value": "off",
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
},
|
||||
{
|
||||
"key": "disablePaste",
|
||||
"type": "select",
|
||||
"labelKey": "settings.user.disablePaste.label",
|
||||
"descriptionKey": "settings.user.disablePaste.description",
|
||||
"group": "settings.user.group",
|
||||
"options": [
|
||||
{
|
||||
"value": "inherit",
|
||||
"labelKey": "settings.user.inherit"
|
||||
},
|
||||
{
|
||||
"value": "on",
|
||||
"labelKey": "settings.user.on"
|
||||
},
|
||||
{
|
||||
"value": "off",
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
}
|
||||
],
|
||||
"host": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "enableRdp",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.host.enableRdp.label",
|
||||
"default": false,
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "enableVnc",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.host.enableVnc.label",
|
||||
"default": false,
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "enableTelnet",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.host.enableTelnet.label",
|
||||
"default": false,
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "rdpPort",
|
||||
"type": "number",
|
||||
"labelKey": "settings.host.rdpPort.label",
|
||||
"min": 1,
|
||||
"max": 65535,
|
||||
"default": 3389,
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "vncPort",
|
||||
"type": "number",
|
||||
"labelKey": "settings.host.vncPort.label",
|
||||
"min": 1,
|
||||
"max": 65535,
|
||||
"default": 5900,
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "telnetPort",
|
||||
"type": "number",
|
||||
"labelKey": "settings.host.telnetPort.label",
|
||||
"min": 1,
|
||||
"max": 65535,
|
||||
"default": 23,
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "rdpSecurity",
|
||||
"type": "string",
|
||||
"labelKey": "settings.host.rdpSecurity.label",
|
||||
"default": "",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "rdpIgnoreCert",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.host.rdpIgnoreCert.label",
|
||||
"default": false,
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "guacamoleConfig",
|
||||
"type": "json",
|
||||
"labelKey": "settings.host.guacamoleConfig.label",
|
||||
"default": {},
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "enableToolbar",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.host.enableToolbar.label",
|
||||
"descriptionKey": "settings.host.enableToolbar.description",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import GuacamoleLite from "guacamole-lite";
|
||||
import { guacLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
GuacamoleTokenService,
|
||||
type GuacamoleRecordingMetadata,
|
||||
} from "./token-service.js";
|
||||
import { getCurrentSettingValue } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { resolveGuacdOptions } from "../../../../src/backend/utils/guacd-config.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { Duplex } from "stream";
|
||||
import type {
|
||||
GuacamoleRecordingMetadata,
|
||||
GuacamoleTokenService,
|
||||
TermixGuacMeta,
|
||||
} from "./token-service.js";
|
||||
import type { GuacdOptions } from "./guacd-config.js";
|
||||
import type { RemoteSessions } from "./sessions.js";
|
||||
import { errorMessage, type RemoteDesktopLogger } from "./log.js";
|
||||
|
||||
/** The recordings.writer service, when the session-recording plugin provides it. */
|
||||
export interface RecordingsWriter {
|
||||
enabledFor: (hostId: number) => Promise<boolean>;
|
||||
createFinished: (input: {
|
||||
hostId: number;
|
||||
userId: string;
|
||||
@@ -25,147 +27,38 @@ export interface RecordingsWriter {
|
||||
}) => Promise<{ id: number }>;
|
||||
}
|
||||
|
||||
let pluginRecordings: RecordingsWriter | null = null;
|
||||
|
||||
export function setPluginRecordings(recordings: RecordingsWriter | null): void {
|
||||
pluginRecordings = recordings;
|
||||
}
|
||||
|
||||
function readGuacdOptions(): { host: string; port: number } {
|
||||
let dbUrl: string | undefined;
|
||||
try {
|
||||
dbUrl = getCurrentSettingValue("guac_url") ?? undefined;
|
||||
} catch {
|
||||
// DB not available yet, use env var defaults
|
||||
}
|
||||
return resolveGuacdOptions(dbUrl);
|
||||
}
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||
const GUACAMOLE_RECORDINGS_DIR =
|
||||
process.env.GUACD_RECORDING_BACKEND_PATH ||
|
||||
path.join(DATA_DIR, "session_recordings", "guacamole");
|
||||
|
||||
type GuacamoleClientConnection = {
|
||||
/** Where the backend reads guacd's recordings from. */
|
||||
export function recordingsDir(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return (
|
||||
env.GUACD_RECORDING_BACKEND_PATH ||
|
||||
path.join(DATA_DIR, "session_recordings", "guacamole")
|
||||
);
|
||||
}
|
||||
|
||||
type ClientConnection = {
|
||||
guacamoleConnectionId?: string;
|
||||
connectionSettings?: {
|
||||
connection?: { type?: string; join?: string; readOnly?: boolean };
|
||||
recording?: GuacamoleRecordingMetadata;
|
||||
termixMeta?: {
|
||||
termixConnectId: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
};
|
||||
termixMeta?: TermixGuacMeta;
|
||||
};
|
||||
close?: () => void;
|
||||
};
|
||||
|
||||
export interface GuacSessionInfo {
|
||||
guacamoleConnectionId: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
openedAt: number;
|
||||
}
|
||||
|
||||
// Keyed by termixConnectId (routes.ts's correlation id), populated once the
|
||||
// primary connection's guacd handshake completes.
|
||||
const guacSessionByConnectId = new Map<string, GuacSessionInfo>();
|
||||
// Keyed by guacd's own guacamoleConnectionId, for join-time lookups.
|
||||
const guacSessionByGuacamoleId = new Map<string, GuacSessionInfo>();
|
||||
export function getGuacSessionByConnectId(
|
||||
connectId: string,
|
||||
userId: string,
|
||||
): GuacSessionInfo | null {
|
||||
const info = guacSessionByConnectId.get(connectId);
|
||||
return info?.ownerUserId === userId ? info : null;
|
||||
}
|
||||
|
||||
export function getGuacSessionInfo(
|
||||
guacamoleConnectionId: string,
|
||||
): GuacSessionInfo | null {
|
||||
return guacSessionByGuacamoleId.get(guacamoleConnectionId) ?? null;
|
||||
}
|
||||
|
||||
async function persistGuacamoleRecording(
|
||||
clientConnection: GuacamoleClientConnection,
|
||||
): Promise<void> {
|
||||
const recording = clientConnection.connectionSettings?.recording;
|
||||
if (!recording) return;
|
||||
|
||||
const resolvedPath = path.resolve(GUACAMOLE_RECORDINGS_DIR, recording.path);
|
||||
const allowedBase = `${path.resolve(GUACAMOLE_RECORDINGS_DIR)}${path.sep}`;
|
||||
if (!resolvedPath.startsWith(allowedBase)) return;
|
||||
|
||||
// guacd may flush/rename the recording just after the websocket closes.
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 10 && !fs.existsSync(resolvedPath);
|
||||
attempt++
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
const guacdPath = recording.guacdPath ?? GUACAMOLE_RECORDINGS_DIR;
|
||||
guacLogger.warn("Guacamole recording file was not found", {
|
||||
operation: "guac_recording_missing",
|
||||
hostId: recording.hostId,
|
||||
path: resolvedPath,
|
||||
guacdPath,
|
||||
hint:
|
||||
"guacd writes the recording to guacdPath, the backend reads it from path. " +
|
||||
"When guacd runs in its own container these must be the same volume — set " +
|
||||
"GUACD_RECORDING_PATH to guacd's mount point and GUACD_RECORDING_BACKEND_PATH to this one.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pluginRecordings) {
|
||||
guacLogger.warn(
|
||||
"Session Recording plugin is off; guacamole recording not saved to the log",
|
||||
{ operation: "guac_recording_no_writer", hostId: recording.hostId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const endedAt = new Date();
|
||||
const startedAt = new Date(recording.startedAt);
|
||||
await pluginRecordings.createFinished({
|
||||
hostId: recording.hostId,
|
||||
userId: recording.userId,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString(),
|
||||
duration: Math.max(
|
||||
0,
|
||||
Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000),
|
||||
),
|
||||
recordingPath: resolvedPath,
|
||||
protocol: recording.protocol,
|
||||
format: "guacamole",
|
||||
});
|
||||
}
|
||||
|
||||
// noServer: core owns the listening socket. guacamole-lite passes this object
|
||||
// straight to ws, and exposes the WebSocketServer it builds, so the plugin can
|
||||
// hand it an upgrade from /plugin-ws/remote-desktop/display. There is no
|
||||
// guacamole port any more.
|
||||
const websocketOptions = {
|
||||
noServer: true,
|
||||
};
|
||||
|
||||
const clientOptions = {
|
||||
const clientOptions = (
|
||||
tokens: GuacamoleTokenService,
|
||||
log: RemoteDesktopLogger,
|
||||
) => ({
|
||||
crypt: {
|
||||
cypher: "AES-256-CBC",
|
||||
key: tokenService.getEncryptionKey(),
|
||||
key: tokens.getEncryptionKey(),
|
||||
},
|
||||
log: {
|
||||
level: "ERRORS",
|
||||
stdLog: (...args: unknown[]) => {
|
||||
guacLogger.info(args.join(" "));
|
||||
},
|
||||
errorLog: (...args: unknown[]) => {
|
||||
guacLogger.error(args.join(" "));
|
||||
},
|
||||
stdLog: (...args: unknown[]) => log.info(args.join(" ")),
|
||||
errorLog: (...args: unknown[]) => log.error(args.join(" ")),
|
||||
},
|
||||
allowedUnencryptedConnectionSettings: {
|
||||
rdp: ["width", "height", "dpi"],
|
||||
@@ -202,150 +95,224 @@ const clientOptions = {
|
||||
"terminal-type": "xterm-256color",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const _origConsoleLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
const msg = args[0];
|
||||
if (typeof msg === "string" && msg.startsWith("New client connection"))
|
||||
return;
|
||||
_origConsoleLog(...args);
|
||||
};
|
||||
export interface GuacamoleServerDeps {
|
||||
log: RemoteDesktopLogger;
|
||||
tokens: GuacamoleTokenService;
|
||||
sessions: RemoteSessions;
|
||||
guacd: () => GuacdOptions;
|
||||
recordings: () => RecordingsWriter | null;
|
||||
/** A socket event has no request behind it, so the row is written as the session's user. */
|
||||
asUser: <T>(userId: string, fn: () => Promise<T>) => Promise<T>;
|
||||
/** Called when a primary session opens; returns what to release on close. */
|
||||
onOpen?: (hostId: number) => () => void;
|
||||
}
|
||||
|
||||
function createGuacServer(): GuacamoleLite {
|
||||
const guacdOptions = readGuacdOptions();
|
||||
const server = new GuacamoleLite(
|
||||
websocketOptions,
|
||||
guacdOptions,
|
||||
clientOptions,
|
||||
);
|
||||
export interface GuacamoleServer {
|
||||
handleUpgrade: (
|
||||
request: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
) => void;
|
||||
/** Rebuilds the server so a changed guacd address takes effect. */
|
||||
restart: () => void;
|
||||
/** Closes a session and everyone watching it. */
|
||||
endSession: (guacamoleConnectionId: string) => boolean;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
server.on("open", (clientConnection: GuacamoleClientConnection) => {
|
||||
guacLogger.info("Guacamole connection opened", {
|
||||
operation: "guac_connection_open",
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
});
|
||||
/**
|
||||
* guacamole-lite in noServer mode: core owns the listening socket and hands
|
||||
* upgrades from /plugin-ws/remote-desktop/display to its WebSocketServer.
|
||||
*/
|
||||
export function createGuacamoleServer(
|
||||
deps: GuacamoleServerDeps,
|
||||
): GuacamoleServer {
|
||||
const { log, sessions } = deps;
|
||||
let server: GuacamoleLite | null = null;
|
||||
|
||||
const termixMeta = clientConnection.connectionSettings?.termixMeta;
|
||||
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
|
||||
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
|
||||
const persistRecording = async (connection: ClientConnection) => {
|
||||
const recording = connection.connectionSettings?.recording;
|
||||
if (!recording) return;
|
||||
|
||||
if (!isJoin && termixMeta && guacamoleConnectionId) {
|
||||
const info: GuacSessionInfo = {
|
||||
guacamoleConnectionId,
|
||||
hostId: termixMeta.hostId,
|
||||
ownerUserId: termixMeta.ownerUserId,
|
||||
protocol: termixMeta.protocol,
|
||||
openedAt: Date.now(),
|
||||
};
|
||||
guacSessionByConnectId.set(termixMeta.termixConnectId, info);
|
||||
guacSessionByGuacamoleId.set(guacamoleConnectionId, info);
|
||||
const dir = recordingsDir();
|
||||
const resolvedPath = path.resolve(dir, recording.path);
|
||||
if (!resolvedPath.startsWith(`${path.resolve(dir)}${path.sep}`)) return;
|
||||
|
||||
// guacd may flush or rename the recording just after the socket closes.
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 10 && !fs.existsSync(resolvedPath);
|
||||
attempt++
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
});
|
||||
|
||||
server.on("close", (clientConnection: GuacamoleClientConnection) => {
|
||||
guacLogger.info("Guacamole connection closed", {
|
||||
operation: "guac_connection_close",
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
});
|
||||
|
||||
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
|
||||
const termixMeta = clientConnection.connectionSettings?.termixMeta;
|
||||
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
|
||||
if (!isJoin && termixMeta && guacamoleConnectionId) {
|
||||
guacSessionByConnectId.delete(termixMeta.termixConnectId);
|
||||
guacSessionByGuacamoleId.delete(guacamoleConnectionId);
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
log.warn("Guacamole recording file was not found", {
|
||||
operation: "guac_recording_missing",
|
||||
hostId: recording.hostId,
|
||||
path: resolvedPath,
|
||||
guacdPath: recording.guacdPath ?? dir,
|
||||
hint:
|
||||
"guacd writes the recording to guacdPath, the backend reads it from path. " +
|
||||
"When guacd runs in its own container these must be the same volume: set " +
|
||||
"GUACD_RECORDING_PATH to guacd's mount point and GUACD_RECORDING_BACKEND_PATH to this one.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
persistGuacamoleRecording(clientConnection).catch((error) => {
|
||||
guacLogger.error("Failed to persist Guacamole recording", error, {
|
||||
operation: "guac_recording_persist_error",
|
||||
const writer = await deps.asUser(recording.userId, async () =>
|
||||
deps.recordings(),
|
||||
);
|
||||
if (!writer) {
|
||||
log.warn(
|
||||
"Session Recording is off; the recording was kept on disk only",
|
||||
{
|
||||
operation: "guac_recording_no_writer",
|
||||
hostId: recording.hostId,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const endedAt = new Date();
|
||||
const startedAt = new Date(recording.startedAt);
|
||||
await deps.asUser(recording.userId, () =>
|
||||
writer.createFinished({
|
||||
hostId: recording.hostId,
|
||||
userId: recording.userId,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString(),
|
||||
duration: Math.max(
|
||||
0,
|
||||
Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000),
|
||||
),
|
||||
recordingPath: resolvedPath,
|
||||
protocol: recording.protocol,
|
||||
format: "guacamole",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const build = (): GuacamoleLite => {
|
||||
// guacamole-lite fills in port 8080 unless it sees a server key, and ws
|
||||
// refuses a port next to noServer, so the port is cleared explicitly.
|
||||
const guacamole = new GuacamoleLite(
|
||||
{ noServer: true, port: undefined },
|
||||
deps.guacd(),
|
||||
clientOptions(deps.tokens, log),
|
||||
);
|
||||
// It also installs its own SIGTERM and SIGINT handlers. Core decides
|
||||
// when the process stops and closes this through deactivate instead.
|
||||
const handlers = guacamole as unknown as {
|
||||
sigTermHandler?: () => void;
|
||||
sigIntHandler?: () => void;
|
||||
};
|
||||
if (handlers.sigTermHandler) {
|
||||
process.off("SIGTERM", handlers.sigTermHandler);
|
||||
}
|
||||
if (handlers.sigIntHandler) {
|
||||
process.off("SIGINT", handlers.sigIntHandler);
|
||||
}
|
||||
|
||||
guacamole.on("open", (connection: ClientConnection) => {
|
||||
const meta = connection.connectionSettings?.termixMeta;
|
||||
const guacamoleConnectionId = connection.guacamoleConnectionId;
|
||||
const isJoin = !!connection.connectionSettings?.connection?.join;
|
||||
if (isJoin || !meta || !guacamoleConnectionId) return;
|
||||
const release = deps.onOpen?.(meta.hostId);
|
||||
sessions.opened(meta, guacamoleConnectionId, release ? [release] : []);
|
||||
});
|
||||
|
||||
guacamole.on("close", (connection: ClientConnection) => {
|
||||
const meta = connection.connectionSettings?.termixMeta;
|
||||
const isJoin = !!connection.connectionSettings?.connection?.join;
|
||||
if (!isJoin && meta) sessions.closed(meta.termixConnectId);
|
||||
persistRecording(connection).catch((error) => {
|
||||
log.error("Failed to persist Guacamole recording", {
|
||||
operation: "guac_recording_persist_error",
|
||||
error: errorMessage(error),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
server.on(
|
||||
"error",
|
||||
(clientConnection: GuacamoleClientConnection, error: Error) => {
|
||||
guacLogger.error("Guacamole connection error", error, {
|
||||
guacamole.on("error", (connection: ClientConnection, error: Error) => {
|
||||
log.error("Guacamole connection error", {
|
||||
operation: "guac_connection_error",
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
type: connection.connectionSettings?.connection?.type,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
});
|
||||
|
||||
return guacamole;
|
||||
};
|
||||
|
||||
const closeServer = () => {
|
||||
if (!server) return;
|
||||
try {
|
||||
server.close();
|
||||
} catch (error) {
|
||||
log.error("Error closing the Guacamole server", {
|
||||
error: errorMessage(error),
|
||||
});
|
||||
}
|
||||
server = null;
|
||||
};
|
||||
|
||||
server = build();
|
||||
|
||||
return {
|
||||
handleUpgrade(request, socket, head) {
|
||||
const wss = (
|
||||
server as unknown as {
|
||||
webSocketServer?: {
|
||||
handleUpgrade: (
|
||||
request: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
done: (ws: unknown) => void,
|
||||
) => void;
|
||||
emit: (event: string, ...args: unknown[]) => void;
|
||||
};
|
||||
} | null
|
||||
)?.webSocketServer;
|
||||
if (!wss) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, request);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
restart() {
|
||||
if (!server) return;
|
||||
closeServer();
|
||||
server = build();
|
||||
},
|
||||
|
||||
endSession(guacamoleConnectionId) {
|
||||
const active = (
|
||||
server as unknown as {
|
||||
activeConnections?: Map<unknown, ClientConnection>;
|
||||
} | null
|
||||
)?.activeConnections;
|
||||
if (!active) return false;
|
||||
let closed = false;
|
||||
for (const connection of [...active.values()]) {
|
||||
const join = connection.connectionSettings?.connection?.join;
|
||||
if (
|
||||
connection.guacamoleConnectionId === guacamoleConnectionId ||
|
||||
join === guacamoleConnectionId
|
||||
) {
|
||||
connection.close?.();
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
return closed;
|
||||
},
|
||||
|
||||
close: closeServer,
|
||||
};
|
||||
}
|
||||
|
||||
let guacServer: GuacamoleLite | null = null;
|
||||
|
||||
/**
|
||||
* Builds the guacamole-lite server. Called from this plugin's activate().
|
||||
* Nothing binds a port: handleGuacamoleUpgrade below feeds it connections.
|
||||
*/
|
||||
export async function startGuacamoleService(): Promise<void> {
|
||||
guacServer = createGuacServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands one upgrade to guacamole-lite's own WebSocketServer.
|
||||
*
|
||||
* guacamole-lite constructs that server internally and has no "attach to this
|
||||
* http server" option, so giving it the upgrade is the only way in. Core has
|
||||
* already authenticated the request by the time this runs.
|
||||
*/
|
||||
export function handleGuacamoleUpgrade(
|
||||
request: import("http").IncomingMessage,
|
||||
socket: import("stream").Duplex,
|
||||
head: Buffer,
|
||||
): void {
|
||||
const server = guacServer as unknown as {
|
||||
webSocketServer?: {
|
||||
handleUpgrade: (
|
||||
request: import("http").IncomingMessage,
|
||||
socket: import("stream").Duplex,
|
||||
head: Buffer,
|
||||
done: (ws: unknown) => void,
|
||||
) => void;
|
||||
emit: (event: string, ...args: unknown[]) => void;
|
||||
};
|
||||
} | null;
|
||||
|
||||
const wss = server?.webSocketServer;
|
||||
if (!wss) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the guacamole-lite WebSocket server. Called from this plugin's
|
||||
* deactivate().
|
||||
*/
|
||||
export async function stopGuacamoleService(): Promise<void> {
|
||||
if (!guacServer) return;
|
||||
try {
|
||||
guacServer.close();
|
||||
} catch (err) {
|
||||
guacLogger.error("Error closing guac server during shutdown", err as Error);
|
||||
}
|
||||
guacServer = null;
|
||||
}
|
||||
|
||||
export async function restartGuacServer(): Promise<void> {
|
||||
// A no-op while the plugin is disabled: creating a new server here would
|
||||
// silently resurrect the WS server behind deactivate()'s back.
|
||||
if (!guacServer) return;
|
||||
try {
|
||||
guacServer.close();
|
||||
} catch (err) {
|
||||
guacLogger.error("Error closing guac server during restart", err as Error);
|
||||
}
|
||||
guacServer = createGuacServer();
|
||||
}
|
||||
|
||||
export { guacServer, tokenService };
|
||||
@@ -0,0 +1,86 @@
|
||||
export type GuacdOptions = {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
const DEFAULT_GUACD_OPTIONS: GuacdOptions = {
|
||||
host: "localhost",
|
||||
port: 4822,
|
||||
};
|
||||
|
||||
function parsePort(value: string | undefined, fallback: number) {
|
||||
const port = parseInt(value || "", 10);
|
||||
return Number.isFinite(port) ? port : fallback;
|
||||
}
|
||||
|
||||
export function parseGuacdUrl(
|
||||
value: string,
|
||||
fallback: GuacdOptions = DEFAULT_GUACD_OPTIONS,
|
||||
): GuacdOptions {
|
||||
const raw = value.trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (raw.includes("://")) {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return {
|
||||
host: url.hostname || fallback.host,
|
||||
port: parsePort(url.port, fallback.port),
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const bracketedIpv6 = raw.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
||||
if (bracketedIpv6) {
|
||||
return {
|
||||
host: bracketedIpv6[1],
|
||||
port: parsePort(bracketedIpv6[2], fallback.port),
|
||||
};
|
||||
}
|
||||
|
||||
const [host, port] = raw.split(":");
|
||||
return {
|
||||
host: host || fallback.host,
|
||||
port: parsePort(port, fallback.port),
|
||||
};
|
||||
}
|
||||
|
||||
export function getGuacdEnvOptions(): GuacdOptions | null {
|
||||
if (process.env.GUACD_URL) {
|
||||
return parseGuacdUrl(process.env.GUACD_URL);
|
||||
}
|
||||
|
||||
if (!process.env.GUACD_HOST && !process.env.GUACD_PORT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
host: process.env.GUACD_HOST || DEFAULT_GUACD_OPTIONS.host,
|
||||
port: parsePort(process.env.GUACD_PORT, DEFAULT_GUACD_OPTIONS.port),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGuacdOptions(dbUrl?: string | null): GuacdOptions {
|
||||
const envOptions = getGuacdEnvOptions();
|
||||
if (envOptions) {
|
||||
return envOptions;
|
||||
}
|
||||
|
||||
if (dbUrl) {
|
||||
return parseGuacdUrl(dbUrl);
|
||||
}
|
||||
|
||||
return DEFAULT_GUACD_OPTIONS;
|
||||
}
|
||||
|
||||
export function formatGuacdOptions(options: GuacdOptions): string {
|
||||
return `${options.host}:${options.port}`;
|
||||
}
|
||||
|
||||
export function getDefaultGuacdUrl(): string {
|
||||
return formatGuacdOptions(resolveGuacdOptions());
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type RemoteProtocol = "rdp" | "vnc" | "telnet";
|
||||
|
||||
export const PLUGIN_ID = "remote-desktop";
|
||||
|
||||
export interface RemoteDesktopHostSettings {
|
||||
enableRdp: boolean;
|
||||
enableVnc: boolean;
|
||||
enableTelnet: boolean;
|
||||
rdpPort: number;
|
||||
vncPort: number;
|
||||
telnetPort: number;
|
||||
rdpSecurity: string;
|
||||
rdpIgnoreCert: boolean;
|
||||
guacamoleConfig: Record<string, unknown>;
|
||||
enableToolbar: boolean;
|
||||
}
|
||||
|
||||
export const ENABLE_KEY: Record<
|
||||
RemoteProtocol,
|
||||
keyof RemoteDesktopHostSettings
|
||||
> = { rdp: "enableRdp", vnc: "enableVnc", telnet: "enableTelnet" };
|
||||
|
||||
export const PORT_KEY: Record<RemoteProtocol, keyof RemoteDesktopHostSettings> =
|
||||
{ rdp: "rdpPort", vnc: "vncPort", telnet: "telnetPort" };
|
||||
|
||||
export const DEFAULT_PORT: Record<RemoteProtocol, number> = {
|
||||
rdp: 3389,
|
||||
vnc: 5900,
|
||||
telnet: 23,
|
||||
};
|
||||
|
||||
export function isRemoteProtocol(value: unknown): value is RemoteProtocol {
|
||||
return value === "rdp" || value === "vnc" || value === "telnet";
|
||||
}
|
||||
|
||||
function asPort(value: unknown): number | undefined {
|
||||
const port = typeof value === "string" ? Number(value) : value;
|
||||
return typeof port === "number" &&
|
||||
Number.isInteger(port) &&
|
||||
port > 0 &&
|
||||
port <= 65535
|
||||
? port
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
let parsed = value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean | undefined {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === 1 || value === "1" || value === "true") return true;
|
||||
if (value === 0 || value === "0" || value === "false") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function readHostSettings(
|
||||
ctx: PluginContext,
|
||||
hostId: number,
|
||||
): Promise<RemoteDesktopHostSettings> {
|
||||
const values = await ctx.settings.getAll("host", hostId);
|
||||
return {
|
||||
enableRdp: values.enableRdp === true,
|
||||
enableVnc: values.enableVnc === true,
|
||||
enableTelnet: values.enableTelnet === true,
|
||||
rdpPort: asPort(values.rdpPort) ?? DEFAULT_PORT.rdp,
|
||||
vncPort: asPort(values.vncPort) ?? DEFAULT_PORT.vnc,
|
||||
telnetPort: asPort(values.telnetPort) ?? DEFAULT_PORT.telnet,
|
||||
rdpSecurity:
|
||||
typeof values.rdpSecurity === "string" ? values.rdpSecurity : "",
|
||||
rdpIgnoreCert: values.rdpIgnoreCert === true,
|
||||
guacamoleConfig: asObject(values.guacamoleConfig) ?? {},
|
||||
enableToolbar: values.enableToolbar !== false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns an imported host row into this plugin's host settings.
|
||||
*
|
||||
* A Termix export carries them under pluginSettings["remote-desktop"]. Older
|
||||
* exports, other tools and a plugin creating a host (proxmox) use the flat
|
||||
* fields the host row had before 2.9.0, including the connectionType-only
|
||||
* shape of hosts from before the per-protocol switches existed.
|
||||
*/
|
||||
export function normalizeImportedHost(
|
||||
raw: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const nested =
|
||||
asObject(asObject(raw.pluginSettings)?.[PLUGIN_ID]) ??
|
||||
({} as Record<string, unknown>);
|
||||
const pick = (key: string, legacy?: string) =>
|
||||
nested[key] !== undefined
|
||||
? nested[key]
|
||||
: raw[key] !== undefined
|
||||
? raw[key]
|
||||
: legacy !== undefined
|
||||
? raw[legacy]
|
||||
: undefined;
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
|
||||
const key = ENABLE_KEY[protocol];
|
||||
const flag = asBool(pick(key));
|
||||
if (flag !== undefined) out[key] = flag;
|
||||
const port = asPort(pick(PORT_KEY[protocol]));
|
||||
if (port !== undefined) out[PORT_KEY[protocol]] = port;
|
||||
}
|
||||
|
||||
const anyFlag = ["enableRdp", "enableVnc", "enableTelnet"].some(
|
||||
(key) => out[key] === true,
|
||||
);
|
||||
if (!anyFlag && isRemoteProtocol(raw.connectionType)) {
|
||||
out[ENABLE_KEY[raw.connectionType]] = true;
|
||||
const port = asPort(raw.port);
|
||||
if (port !== undefined && out[PORT_KEY[raw.connectionType]] === undefined) {
|
||||
out[PORT_KEY[raw.connectionType]] = port;
|
||||
}
|
||||
}
|
||||
|
||||
const security = pick("rdpSecurity", "security");
|
||||
if (typeof security === "string" && security) out.rdpSecurity = security;
|
||||
const ignoreCert = asBool(pick("rdpIgnoreCert", "ignoreCert"));
|
||||
if (ignoreCert !== undefined) out.rdpIgnoreCert = ignoreCert;
|
||||
const config = asObject(pick("guacamoleConfig"));
|
||||
if (config && Object.keys(config).length > 0) out.guacamoleConfig = config;
|
||||
const toolbar = asBool(pick("enableToolbar", "enableTerminalToolbar"));
|
||||
if (toolbar !== undefined) out.enableToolbar = toolbar;
|
||||
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
|
||||
/** User setting key to the guacd parameter it sets. */
|
||||
const USER_DEFAULT_PARAMS: Record<string, string> = {
|
||||
colorDepth: "color-depth",
|
||||
resizeMethod: "resize-method",
|
||||
forceLossless: "force-lossless",
|
||||
enableWallpaper: "enable-wallpaper",
|
||||
enableFontSmoothing: "enable-font-smoothing",
|
||||
enableDesktopComposition: "enable-desktop-composition",
|
||||
disableAudio: "disable-audio",
|
||||
enablePrinting: "enable-printing",
|
||||
enableDrive: "enable-drive",
|
||||
disableCopy: "disable-copy",
|
||||
disablePaste: "disable-paste",
|
||||
};
|
||||
|
||||
/**
|
||||
* The user's RDP defaults as guacd parameters. "inherit" leaves a parameter
|
||||
* to the host and guacd; on and off become booleans.
|
||||
*/
|
||||
export function userDefaultParams(
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = {};
|
||||
for (const [key, param] of Object.entries(USER_DEFAULT_PARAMS)) {
|
||||
const value = values[key];
|
||||
if (value === undefined || value === null || value === "inherit") continue;
|
||||
if (key === "colorDepth") {
|
||||
const depth = Number(value);
|
||||
if (Number.isInteger(depth) && depth > 0) params[param] = depth;
|
||||
} else if (key === "resizeMethod") {
|
||||
if (typeof value === "string" && value) params[param] = value;
|
||||
} else if (value === "on" || value === "off") {
|
||||
params[param] = value === "on";
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function readUserDefaults(
|
||||
ctx: PluginContext,
|
||||
userId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return userDefaultParams(await ctx.settings.getAll("user", userId));
|
||||
}
|
||||
@@ -1,63 +1,108 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { RemoteSessions } from "./sessions.js";
|
||||
import {
|
||||
getGuacSessionInfo,
|
||||
handleGuacamoleUpgrade,
|
||||
restartGuacServer,
|
||||
setPluginRecordings,
|
||||
startGuacamoleService,
|
||||
stopGuacamoleService,
|
||||
tokenService,
|
||||
createGuacamoleServer,
|
||||
type RecordingsWriter,
|
||||
} from "./guacamole-server.js";
|
||||
import { startRemoteDesktopService } from "./routes.js";
|
||||
import { resolveGuacdOptions } from "./guacd-config.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
import { createLiveSessions } from "./live-sessions.js";
|
||||
import { normalizeImportedHost } from "./host-settings.js";
|
||||
import { createLogger } from "./log.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
const log = createLogger(ctx.log);
|
||||
const tokens = new GuacamoleTokenService(log);
|
||||
const sessions = new RemoteSessions();
|
||||
ctx.disposables.add(() => sessions.clear());
|
||||
|
||||
// Optional: without the session-recording plugin, a guacd recording is
|
||||
// still made on disk but never gets a row, so it never shows in the list.
|
||||
const recordings = ctx.services.get<RecordingsWriter>("recordings.writer");
|
||||
setPluginRecordings("createFinished" in recordings ? recordings : null);
|
||||
ctx.disposables.add(() => setPluginRecordings(null));
|
||||
// The admin setting, unless GUACD_URL / GUACD_HOST / GUACD_PORT override it.
|
||||
let guacdUrl = (await ctx.settings.get<string>("guacdUrl")) ?? "";
|
||||
const guacd = () => resolveGuacdOptions(guacdUrl || undefined);
|
||||
const enabled = async () =>
|
||||
(await ctx.settings.get<boolean>("enabled")) !== false;
|
||||
|
||||
await startGuacamoleService();
|
||||
// Optional: without session-recording nothing is recorded.
|
||||
const recordings = (): RecordingsWriter | null => {
|
||||
const service =
|
||||
ctx.services.get<Partial<RecordingsWriter>>("recordings.writer");
|
||||
return typeof service.createFinished === "function" &&
|
||||
typeof service.enabledFor === "function"
|
||||
? (service as RecordingsWriter)
|
||||
: null;
|
||||
};
|
||||
|
||||
startRemoteDesktopService(ctx.http.router());
|
||||
// guacamole-lite prints every new connection to stdout.
|
||||
const consoleLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
if (
|
||||
typeof args[0] === "string" &&
|
||||
args[0].startsWith("New client connection")
|
||||
)
|
||||
return;
|
||||
consoleLog(...args);
|
||||
};
|
||||
ctx.disposables.add(() => {
|
||||
console.log = consoleLog;
|
||||
});
|
||||
|
||||
const server = createGuacamoleServer({
|
||||
log,
|
||||
tokens,
|
||||
sessions,
|
||||
guacd,
|
||||
recordings,
|
||||
asUser: (userId, fn) => ctx.asUser(userId, fn),
|
||||
onOpen: (hostId) => ctx.hosts.trackSession(hostId),
|
||||
});
|
||||
ctx.disposables.add(() => server.close());
|
||||
|
||||
ctx.settings.onChange("guacdUrl", (value) => {
|
||||
guacdUrl = typeof value === "string" ? value : "";
|
||||
server.restart();
|
||||
});
|
||||
|
||||
registerRoutes(ctx.http.router(), {
|
||||
ctx,
|
||||
log,
|
||||
tokens,
|
||||
sessions,
|
||||
guacd,
|
||||
enabled,
|
||||
recordings,
|
||||
});
|
||||
|
||||
// guacamole-lite owns its own WebSocketServer, so it takes the raw upgrade.
|
||||
//
|
||||
// Public because a Guacamole display authenticates with the encrypted,
|
||||
// single-use connection token minted by POST /token, not with a session
|
||||
// JWT. guacamole-lite decrypts and validates that token itself, and the
|
||||
// route that mints one is authenticated normally.
|
||||
// Public because a display authenticates with the encrypted, single-use
|
||||
// token minted by an authenticated route, not with a session JWT.
|
||||
ctx.ws.upgrade(
|
||||
"/display",
|
||||
(request, socket, head) =>
|
||||
handleGuacamoleUpgrade(
|
||||
request as Parameters<typeof handleGuacamoleUpgrade>[0],
|
||||
socket as Parameters<typeof handleGuacamoleUpgrade>[1],
|
||||
server.handleUpgrade(
|
||||
request as Parameters<typeof server.handleUpgrade>[0],
|
||||
socket as Parameters<typeof server.handleUpgrade>[1],
|
||||
head as Buffer,
|
||||
),
|
||||
{ public: true },
|
||||
);
|
||||
|
||||
// What collab, session-sharing and the admin guacd setting need from a
|
||||
// running Guacamole server. Revoked automatically on deactivate, and
|
||||
// hosts/guacamole-sessions.ts degrades safely while it is gone.
|
||||
ctx.registry.provide("remote-desktop.sessions", {
|
||||
restart: restartGuacServer,
|
||||
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) =>
|
||||
tokenService.createJoinToken(guacamoleConnectionId, readOnly),
|
||||
getSessionInfo: getGuacSessionInfo,
|
||||
});
|
||||
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
|
||||
ctx.services.provide(
|
||||
"sessions.live",
|
||||
createLiveSessions(protocol, {
|
||||
sessions,
|
||||
tokens,
|
||||
endSession: server.endSession,
|
||||
}),
|
||||
{ name: protocol },
|
||||
);
|
||||
}
|
||||
|
||||
ctx.log.info(
|
||||
"Remote Desktop mounted at /plugin-api/remote-desktop and /plugin-ws/remote-desktop/display",
|
||||
ctx.registry.provide(
|
||||
"remote-desktop.hostImportNormalizer",
|
||||
normalizeImportedHost,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
await stopGuacamoleService();
|
||||
}
|
||||
export async function deactivate() {}
|
||||
@@ -0,0 +1,145 @@
|
||||
import net from "net";
|
||||
import type { Client } from "ssh2";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { RemoteDesktopLogger } from "./log.js";
|
||||
|
||||
/** The tunnels plugin's service, as ctx.services.get("tunnels.access") returns it. */
|
||||
interface TunnelsAccess {
|
||||
forward(
|
||||
sourceHostId: number,
|
||||
target: { targetHost: string; targetPort: number; bindHost?: string },
|
||||
options?: { name?: string; idleTimeoutMs?: number },
|
||||
): Promise<{
|
||||
bindHost: string;
|
||||
bindPort: number;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** The tunnels plugin keeps these names for on-demand forwards. */
|
||||
const TUNNEL_NAME_PREFIX = "web:";
|
||||
/** A forward nothing is using closes itself after this long. */
|
||||
const FORWARD_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface JumpTunnel {
|
||||
port: number;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface JumpTunnelRequest {
|
||||
jumpHosts: Array<{ hostId: number }>;
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
bindHost: string;
|
||||
/** Names the forward, so each session gets one of its own. */
|
||||
connectId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A local port guacd can dial that reaches the target through the host's
|
||||
* jump hosts.
|
||||
*
|
||||
* One hop is exactly what the tunnels plugin's forward does, so that is used
|
||||
* when the plugin is running and the user may use it. A chain, or no tunnels
|
||||
* plugin, goes through ctx.ssh.jumpChain with a listener of our own. Either
|
||||
* way the caller closes it when the session ends.
|
||||
*/
|
||||
export async function openJumpTunnel(
|
||||
ctx: PluginContext,
|
||||
log: RemoteDesktopLogger,
|
||||
request: JumpTunnelRequest,
|
||||
): Promise<JumpTunnel> {
|
||||
if (request.jumpHosts.length === 1) {
|
||||
const viaTunnels = await forwardThroughTunnels(ctx, log, request);
|
||||
if (viaTunnels) return viaTunnels;
|
||||
}
|
||||
return forwardThroughChain(ctx, request);
|
||||
}
|
||||
|
||||
async function forwardThroughTunnels(
|
||||
ctx: PluginContext,
|
||||
log: RemoteDesktopLogger,
|
||||
request: JumpTunnelRequest,
|
||||
): Promise<JumpTunnel | null> {
|
||||
const tunnels = ctx.services.get<Partial<TunnelsAccess>>("tunnels.access");
|
||||
if (typeof tunnels.forward !== "function") return null;
|
||||
const hopId = request.jumpHosts[0].hostId;
|
||||
try {
|
||||
const handle = await tunnels.forward(
|
||||
hopId,
|
||||
{
|
||||
targetHost: request.targetHost,
|
||||
targetPort: request.targetPort,
|
||||
bindHost: request.bindHost,
|
||||
},
|
||||
{
|
||||
name: `${TUNNEL_NAME_PREFIX}${hopId}:rd-${request.connectId}`,
|
||||
idleTimeoutMs: FORWARD_IDLE_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
return {
|
||||
port: handle.bindPort,
|
||||
close: () => void handle.close().catch(() => undefined),
|
||||
};
|
||||
} catch (error) {
|
||||
// A user without the tunnels permission still reaches their host.
|
||||
log.warn("Tunnels forward failed, using a jump chain instead", {
|
||||
operation: "guac_tunnels_forward",
|
||||
hopId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardThroughChain(
|
||||
ctx: PluginContext,
|
||||
request: JumpTunnelRequest,
|
||||
): Promise<JumpTunnel> {
|
||||
// The chain dials the first hop through that hop's own SOCKS5 settings;
|
||||
// the target host's proxy config does not apply to it.
|
||||
const { client, dispose } = await ctx.ssh.jumpChain<Client>(
|
||||
request.jumpHosts,
|
||||
);
|
||||
|
||||
const sockets = new Set<net.Socket>();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
client.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
request.targetHost,
|
||||
request.targetPort,
|
||||
(error, stream) => {
|
||||
if (error) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
socket.pipe(stream).pipe(socket);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
let port: number;
|
||||
try {
|
||||
port = await new Promise<number>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, request.bindHost, () =>
|
||||
resolve((server.address() as net.AddressInfo).port),
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
await dispose();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
close: () => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
server.close();
|
||||
void dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { GuacamoleTokenService } from "./token-service.js";
|
||||
import type { RemoteSessions } from "./sessions.js";
|
||||
import type { RemoteProtocol } from "./host-settings.js";
|
||||
|
||||
/** sessions.live v1, as session-sharing reads it (plugins/session-sharing/src/backend/live.ts). */
|
||||
export interface LiveSessionInfo {
|
||||
id: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
tabInstanceId: string | null;
|
||||
}
|
||||
|
||||
export interface RemoteLiveSessions {
|
||||
getSession: (sessionId: string) => LiveSessionInfo | null;
|
||||
ownerEndSession: (sessionId: string, reason: string) => void;
|
||||
createViewerToken: (sessionId: string, readOnly: boolean) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One provider per protocol. A session id is guacd's own connection id,
|
||||
* which is what a viewer's join token names.
|
||||
*/
|
||||
export function createLiveSessions(
|
||||
protocol: RemoteProtocol,
|
||||
deps: {
|
||||
sessions: RemoteSessions;
|
||||
tokens: GuacamoleTokenService;
|
||||
endSession: (guacamoleConnectionId: string) => boolean;
|
||||
},
|
||||
): RemoteLiveSessions {
|
||||
const find = (sessionId: string) => {
|
||||
const session = deps.sessions.byGuacamole(sessionId);
|
||||
return session && session.protocol === protocol ? session : null;
|
||||
};
|
||||
|
||||
return {
|
||||
getSession(sessionId) {
|
||||
const session = find(sessionId);
|
||||
if (!session) return null;
|
||||
return {
|
||||
id: session.guacamoleConnectionId,
|
||||
userId: session.ownerUserId,
|
||||
hostId: session.hostId,
|
||||
hostName: session.hostName,
|
||||
isConnected: true,
|
||||
createdAt: session.openedAt,
|
||||
tabInstanceId: session.tabInstanceId,
|
||||
};
|
||||
},
|
||||
|
||||
ownerEndSession(sessionId) {
|
||||
if (find(sessionId)) deps.endSession(sessionId);
|
||||
},
|
||||
|
||||
createViewerToken(sessionId, readOnly) {
|
||||
if (!find(sessionId)) {
|
||||
throw new Error(`No live ${protocol} session ${sessionId}`);
|
||||
}
|
||||
return deps.tokens.createJoinToken(sessionId, readOnly);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PluginLogger } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type Meta = Record<string, unknown>;
|
||||
|
||||
export interface RemoteDesktopLogger {
|
||||
info: (message: string, meta?: Meta) => void;
|
||||
warn: (message: string, meta?: Meta) => void;
|
||||
error: (message: string, meta?: Meta) => void;
|
||||
}
|
||||
|
||||
function withMeta(message: string, meta?: Meta): string {
|
||||
return meta && Object.keys(meta).length > 0
|
||||
? `${message} ${JSON.stringify(meta)}`
|
||||
: message;
|
||||
}
|
||||
|
||||
/** ctx.log with the structured context the old guac logger carried. */
|
||||
export function createLogger(log: PluginLogger): RemoteDesktopLogger {
|
||||
return {
|
||||
info: (message, meta) => log.info(withMeta(message, meta)),
|
||||
warn: (message, meta) => log.warn(withMeta(message, meta)),
|
||||
error: (message, meta) => log.error(withMeta(message, meta)),
|
||||
};
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown, fallback = "Unknown error") {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === "string" && error) return error;
|
||||
return fallback;
|
||||
}
|
||||
@@ -34,31 +34,3 @@ export function resolveRdpDomain(
|
||||
? promptedDomain
|
||||
: storedDomain;
|
||||
}
|
||||
|
||||
type SharedRdpAuthResolution =
|
||||
| { source: "personal-override" }
|
||||
| { source: "owner-shared"; authType: string }
|
||||
| { source: "secretless" }
|
||||
| { source: "required" }
|
||||
| null;
|
||||
|
||||
export function resolveRdpAuthTypeForConnect({
|
||||
storedAuthType,
|
||||
credentialId,
|
||||
sharedResolution,
|
||||
}: {
|
||||
storedAuthType?: string | null;
|
||||
credentialId?: number | null;
|
||||
sharedResolution?: SharedRdpAuthResolution;
|
||||
}): string {
|
||||
if (sharedResolution === undefined) {
|
||||
return storedAuthType || (credentialId ? "credential" : "direct");
|
||||
}
|
||||
if (sharedResolution?.source === "personal-override") return "credential";
|
||||
if (sharedResolution?.source === "owner-shared") {
|
||||
return sharedResolution.authType;
|
||||
}
|
||||
return sharedResolution?.source === "secretless" && storedAuthType === "none"
|
||||
? "none"
|
||||
: "direct";
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,116 @@
|
||||
import type { TermixGuacMeta } from "./token-service.js";
|
||||
|
||||
export interface RemoteSession {
|
||||
guacamoleConnectionId: string;
|
||||
termixConnectId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
ownerUserId: string;
|
||||
protocol: TermixGuacMeta["protocol"];
|
||||
tabInstanceId: string | null;
|
||||
openedAt: number;
|
||||
}
|
||||
|
||||
type Cleanup = () => void;
|
||||
|
||||
/** How long a minted token's tunnel waits for guacd to connect through it. */
|
||||
export const PENDING_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Live guacd sessions and what each one holds open.
|
||||
*
|
||||
* A connect request builds its jump tunnel or VNC proxy before guacd dials
|
||||
* in, so those resources are parked under the request's connect id and move
|
||||
* onto the session when it opens. They are released when the session
|
||||
* closes, when guacd never shows up, or when the plugin stops.
|
||||
*/
|
||||
export class RemoteSessions {
|
||||
private readonly pending = new Map<
|
||||
string,
|
||||
{ cleanups: Cleanup[]; timer: ReturnType<typeof setTimeout> }
|
||||
>();
|
||||
private readonly byConnectId = new Map<string, RemoteSession>();
|
||||
private readonly byGuacamoleId = new Map<string, RemoteSession>();
|
||||
private readonly cleanupsByConnectId = new Map<string, Cleanup[]>();
|
||||
|
||||
constructor(private readonly pendingTimeoutMs = PENDING_TIMEOUT_MS) {}
|
||||
|
||||
/** Resources for a connection that guacd has not opened yet. */
|
||||
park(termixConnectId: string, cleanups: Cleanup[]): void {
|
||||
if (cleanups.length === 0) return;
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(termixConnectId);
|
||||
run(cleanups);
|
||||
}, this.pendingTimeoutMs);
|
||||
timer.unref?.();
|
||||
this.pending.set(termixConnectId, { cleanups, timer });
|
||||
}
|
||||
|
||||
opened(
|
||||
meta: TermixGuacMeta,
|
||||
guacamoleConnectionId: string,
|
||||
extra: Cleanup[] = [],
|
||||
): RemoteSession {
|
||||
const session: RemoteSession = {
|
||||
guacamoleConnectionId,
|
||||
termixConnectId: meta.termixConnectId,
|
||||
hostId: meta.hostId,
|
||||
hostName: meta.hostName,
|
||||
ownerUserId: meta.ownerUserId,
|
||||
protocol: meta.protocol,
|
||||
tabInstanceId: meta.tabInstanceId ?? null,
|
||||
openedAt: Date.now(),
|
||||
};
|
||||
const parked = this.pending.get(meta.termixConnectId);
|
||||
if (parked) {
|
||||
clearTimeout(parked.timer);
|
||||
this.pending.delete(meta.termixConnectId);
|
||||
}
|
||||
this.cleanupsByConnectId.set(meta.termixConnectId, [
|
||||
...(parked?.cleanups ?? []),
|
||||
...extra,
|
||||
]);
|
||||
this.byConnectId.set(meta.termixConnectId, session);
|
||||
this.byGuacamoleId.set(guacamoleConnectionId, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
closed(termixConnectId: string): void {
|
||||
const session = this.byConnectId.get(termixConnectId);
|
||||
if (session) this.byGuacamoleId.delete(session.guacamoleConnectionId);
|
||||
this.byConnectId.delete(termixConnectId);
|
||||
run(this.cleanupsByConnectId.get(termixConnectId) ?? []);
|
||||
this.cleanupsByConnectId.delete(termixConnectId);
|
||||
}
|
||||
|
||||
byConnect(termixConnectId: string): RemoteSession | null {
|
||||
return this.byConnectId.get(termixConnectId) ?? null;
|
||||
}
|
||||
|
||||
byGuacamole(guacamoleConnectionId: string): RemoteSession | null {
|
||||
return this.byGuacamoleId.get(guacamoleConnectionId) ?? null;
|
||||
}
|
||||
|
||||
/** Releases everything, for deactivate. */
|
||||
clear(): void {
|
||||
for (const { cleanups, timer } of this.pending.values()) {
|
||||
clearTimeout(timer);
|
||||
run(cleanups);
|
||||
}
|
||||
this.pending.clear();
|
||||
for (const cleanups of this.cleanupsByConnectId.values()) run(cleanups);
|
||||
this.cleanupsByConnectId.clear();
|
||||
this.byConnectId.clear();
|
||||
this.byGuacamoleId.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function run(cleanups: Cleanup[]): void {
|
||||
for (const cleanup of cleanups) {
|
||||
try {
|
||||
cleanup();
|
||||
} catch {
|
||||
// One failed close must not keep the rest open.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type {
|
||||
PluginSsh,
|
||||
PluginSshConnection,
|
||||
PluginSshConnectOptions,
|
||||
PluginSshHost,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
|
||||
let current: PluginSsh | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setPluginSsh(ssh: PluginSsh | null): void {
|
||||
current = ssh;
|
||||
}
|
||||
|
||||
export function pluginSsh(): PluginSsh {
|
||||
if (!current) throw new Error("The plugin is not active");
|
||||
return current;
|
||||
}
|
||||
|
||||
/** withConnection with ssh2's Client type filled in. */
|
||||
export function withSshConnection<T>(
|
||||
host: number | PluginSshHost,
|
||||
options: PluginSshConnectOptions & { pool: string },
|
||||
fn: (client: Client) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return pluginSsh().withConnection<T, Client>(host, options, fn);
|
||||
}
|
||||
|
||||
/** connect with ssh2's Client type filled in. */
|
||||
export function connectSsh(
|
||||
host: number | PluginSshHost,
|
||||
options?: PluginSshConnectOptions,
|
||||
): Promise<PluginSshConnection<Client>> {
|
||||
return pluginSsh().connect<Client>(host, options);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import crypto from "crypto";
|
||||
import { guacLogger } from "../../../../src/backend/utils/logger.js";
|
||||
|
||||
interface TokenLogger {
|
||||
warn: (message: string, meta?: Record<string, unknown>) => void;
|
||||
error: (message: string, meta?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export interface GuacamoleConnectionSettings {
|
||||
type?: "rdp" | "vnc" | "telnet";
|
||||
@@ -33,8 +37,10 @@ export interface GuacamoleConnectionSettings {
|
||||
export interface TermixGuacMeta {
|
||||
termixConnectId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
ownerUserId: string;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
tabInstanceId: string | null;
|
||||
}
|
||||
|
||||
export interface GuacamoleToken {
|
||||
@@ -57,21 +63,19 @@ export interface GuacamoleRecordingMetadata {
|
||||
const CIPHER = "aes-256-cbc";
|
||||
const KEY_LENGTH = 32;
|
||||
|
||||
const silentLogger: TokenLogger = { warn: () => {}, error: () => {} };
|
||||
|
||||
/**
|
||||
* Encrypts the connection tokens guacamole-lite decrypts. Built in activate;
|
||||
* the key is derived from the environment, so a restart keeps it.
|
||||
*/
|
||||
export class GuacamoleTokenService {
|
||||
private static instance: GuacamoleTokenService;
|
||||
private encryptionKey: Buffer;
|
||||
|
||||
private constructor() {
|
||||
constructor(private readonly log: TokenLogger = silentLogger) {
|
||||
this.encryptionKey = this.initializeKey();
|
||||
}
|
||||
|
||||
static getInstance(): GuacamoleTokenService {
|
||||
if (!GuacamoleTokenService.instance) {
|
||||
GuacamoleTokenService.instance = new GuacamoleTokenService();
|
||||
}
|
||||
return GuacamoleTokenService.instance;
|
||||
}
|
||||
|
||||
private initializeKey(): Buffer {
|
||||
const existingKey = process.env.GUACAMOLE_ENCRYPTION_KEY;
|
||||
if (existingKey) {
|
||||
@@ -91,12 +95,9 @@ export class GuacamoleTokenService {
|
||||
.digest();
|
||||
}
|
||||
|
||||
guacLogger.warn(
|
||||
"No persistent encryption key found, generating random key",
|
||||
{
|
||||
operation: "guac_key_generation",
|
||||
},
|
||||
);
|
||||
this.log.warn("No persistent encryption key found, generating random key", {
|
||||
operation: "guac_key_generation",
|
||||
});
|
||||
return crypto.randomBytes(KEY_LENGTH);
|
||||
}
|
||||
|
||||
@@ -134,8 +135,9 @@ export class GuacamoleTokenService {
|
||||
|
||||
return JSON.parse(decrypted) as GuacamoleToken;
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to decrypt guacamole token", error, {
|
||||
this.log.error("Failed to decrypt guacamole token", {
|
||||
operation: "guac_token_decrypt_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { watchGuacamoleConnectionId } from "./guacamole-session-id";
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
@@ -14,40 +13,42 @@ import {
|
||||
type GuacamoleDisplayHandle,
|
||||
type GuacamoleTouchMode,
|
||||
} from "./GuacamoleDisplay.tsx";
|
||||
import { getSSHHosts, logActivity, isElectron } from "@/main-axios";
|
||||
import { getGuacamoleTokenFromHost, getGuacdStatus } from "./guacamole-api";
|
||||
import { readConfiguredDimension } from "./guacamole-display-size.ts";
|
||||
import { getGuacamoleToken, parseGuacamoleConfig } from "./guacamole-api";
|
||||
import {
|
||||
resolveConnectionOrigin,
|
||||
type ConnectionOrigin,
|
||||
} from "@/lib/connection-origin.ts";
|
||||
import { getGuacamoleToken } from "./guacamole-api";
|
||||
import {
|
||||
useHost,
|
||||
useTranslation,
|
||||
useConnectionRetry,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { GuacamoleToolbar } from "./GuacamoleToolbar.tsx";
|
||||
import { GuacamoleFileBrowser } from "./GuacamoleFileBrowser.tsx";
|
||||
import { describeUploadError } from "./guacamole-filesystem.ts";
|
||||
import { canUploadToRdpDrive } from "./guacamole-file-drop.ts";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
import {
|
||||
Button,
|
||||
ConnectionLogProvider,
|
||||
ConnectionScreen,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
Input,
|
||||
PasswordInput,
|
||||
isElectron,
|
||||
logActivity,
|
||||
resolveConnectionOrigin,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import type { SSHHost } from "@/types";
|
||||
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
|
||||
import { resolveConnectionDefaults } from "@/lib/connection-defaults";
|
||||
type ConnectionOrigin,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { GuacamoleConfig } from "./guacamole-config";
|
||||
import {
|
||||
errorMessage,
|
||||
hostRemoteOptions,
|
||||
type Protocol,
|
||||
type RemoteHostLogin,
|
||||
} from "./host-remote";
|
||||
import { GuacamoleToolbar } from "./GuacamoleToolbar.tsx";
|
||||
import { GuacamoleFileBrowser } from "./GuacamoleFileBrowser.tsx";
|
||||
import { describeUploadError } from "./guacamole-filesystem.ts";
|
||||
import { canUploadToRdpDrive } from "./guacamole-file-drop.ts";
|
||||
import { needsRdpCredentialPrompt } from "./rdp-credential-prompt";
|
||||
|
||||
interface GuacamoleAppProps {
|
||||
@@ -59,10 +60,48 @@ interface GuacamoleAppProps {
|
||||
quickConnectHost?: GuacamoleQuickHost;
|
||||
}
|
||||
|
||||
/** What GuacamoleApp needs from a host that has no database row. */
|
||||
export type GuacamoleQuickHost = GuacamoleAppInnerProps["hostConfig"] & {
|
||||
/**
|
||||
* What a session needs from its host. A saved host brings its options; a
|
||||
* quick-connect host has no row, so it also carries its address and login.
|
||||
*/
|
||||
export interface GuacamoleHostConfig {
|
||||
name?: string;
|
||||
};
|
||||
ip: string;
|
||||
connectionType?: Protocol;
|
||||
domain?: string;
|
||||
guacamoleConfig?: GuacamoleConfig;
|
||||
rdpAuthType?: string;
|
||||
authOverrides?: RemoteHostLogin["authOverrides"];
|
||||
syncId?: string | null;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
showToolbar?: boolean;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export type GuacamoleQuickHost = GuacamoleHostConfig;
|
||||
|
||||
function savedHostConfig(
|
||||
host: RemoteHostLogin,
|
||||
protocol: Protocol | undefined,
|
||||
): GuacamoleHostConfig {
|
||||
const options = hostRemoteOptions(host);
|
||||
return {
|
||||
name: host.name ?? undefined,
|
||||
ip: host.ip ?? "",
|
||||
connectionType:
|
||||
protocol ??
|
||||
(options.enableRdp ? "rdp" : options.enableVnc ? "vnc" : "telnet"),
|
||||
domain: host.domain,
|
||||
guacamoleConfig: options.guacamoleConfig,
|
||||
rdpAuthType: host.rdpAuthType,
|
||||
authOverrides: host.authOverrides,
|
||||
syncId: host.syncId,
|
||||
connectionOrigin: host.connectionOrigin,
|
||||
showToolbar: options.enableToolbar,
|
||||
};
|
||||
}
|
||||
|
||||
export interface GuacamoleAppHandle {
|
||||
disconnect: () => void;
|
||||
@@ -77,50 +116,20 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const defaults = useConnectionDefaults();
|
||||
const [hostConfig, setHostConfig] = useState<GuacamoleQuickHost | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const savedHost = useHost(quickConnectHost ? undefined : hostId);
|
||||
const hostConfig: GuacamoleHostConfig | null = quickConnectHost
|
||||
? quickConnectHost
|
||||
: savedHost
|
||||
? savedHostConfig(savedHost as RemoteHostLogin, protocol)
|
||||
: null;
|
||||
// The host list loads with the shell; a tab restored before it arrives
|
||||
// waits for it instead of reporting the host missing.
|
||||
const [waited, setWaited] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!defaults.ready) return;
|
||||
if (quickConnectHost) {
|
||||
const connectionType = protocol ?? quickConnectHost.connectionType;
|
||||
setHostConfig({
|
||||
...quickConnectHost,
|
||||
guacamoleConfig: resolveConnectionDefaults(
|
||||
connectionType === "rdp" ? defaults.rdp : {},
|
||||
{},
|
||||
),
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!hostId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const host = hosts.find((h) => h.id === parseInt(hostId, 10));
|
||||
if (!host) {
|
||||
setHostConfig(null);
|
||||
return;
|
||||
}
|
||||
const connectionType = protocol ?? host.connectionType;
|
||||
const protocolDefaults = connectionType === "rdp" ? defaults.rdp : {};
|
||||
setHostConfig({
|
||||
...host,
|
||||
guacamoleConfig: resolveConnectionDefaults(
|
||||
protocolDefaults,
|
||||
parseGuacamoleConfig(host.guacamoleConfig),
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(() => setHostConfig(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [hostId, protocol, defaults.ready, defaults.rdp, quickConnectHost]);
|
||||
const timer = setTimeout(() => setWaited(true), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
const loading = !hostConfig && !!hostId && !waited;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -159,23 +168,7 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
|
||||
|
||||
interface GuacamoleAppInnerProps {
|
||||
hostId: number;
|
||||
hostConfig: Pick<
|
||||
SSHHost,
|
||||
| "connectionType"
|
||||
| "domain"
|
||||
| "guacamoleConfig"
|
||||
| "rdpAuthType"
|
||||
| "authOverrides"
|
||||
| "syncId"
|
||||
| "connectionOrigin"
|
||||
| "ip"
|
||||
| "rdpPort"
|
||||
| "vncPort"
|
||||
| "rdpUser"
|
||||
| "rdpPassword"
|
||||
| "vncUser"
|
||||
| "vncPassword"
|
||||
> & { enableTerminalToolbar?: boolean };
|
||||
hostConfig: GuacamoleHostConfig;
|
||||
hostName: string;
|
||||
tabId?: string;
|
||||
protocol?: "rdp" | "vnc" | "telnet";
|
||||
@@ -223,7 +216,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
const [toolbarHidden, setToolbarHidden] = useState(false);
|
||||
const [pendingUploads, setPendingUploads] = useState<File[]>([]);
|
||||
|
||||
const guacConfig = parseGuacamoleConfig(hostConfig.guacamoleConfig);
|
||||
const guacConfig = hostConfig.guacamoleConfig ?? {};
|
||||
const allowUpload = guacConfig.disableUpload !== true;
|
||||
const allowDownload = guacConfig.disableDownload !== true;
|
||||
|
||||
@@ -311,10 +304,10 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
let resolvedOrigin: ConnectionOrigin = "local";
|
||||
|
||||
if (isElectron()) {
|
||||
resolvedOrigin = await resolveConnectionOrigin({
|
||||
connectionType: resolvedProtocolForConnect,
|
||||
connectionOrigin: hostConfig.connectionOrigin,
|
||||
});
|
||||
resolvedOrigin = await resolveConnectionOrigin(
|
||||
{ connectionOrigin: hostConfig.connectionOrigin },
|
||||
{ defaultRemote: true },
|
||||
);
|
||||
if (resolvedOrigin === "remote") {
|
||||
const remoteConfig = (await window.electronAPI?.invoke?.(
|
||||
"get-remote-sync-config",
|
||||
@@ -331,6 +324,9 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
message: t("remoteDesktop.checkingGuacd"),
|
||||
});
|
||||
const status = await getGuacdStatus(resolvedOrigin);
|
||||
if (status.enabled === false) {
|
||||
throw new Error(t("remoteDesktop.disabled"));
|
||||
}
|
||||
if (status.guacd.status !== "connected") {
|
||||
throw new Error(t("remoteDesktop.guacdUnavailable"));
|
||||
}
|
||||
@@ -351,21 +347,12 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
{
|
||||
protocol: resolvedProtocolForConnect,
|
||||
hostname: hostConfig.ip,
|
||||
port:
|
||||
resolvedProtocolForConnect === "vnc"
|
||||
? hostConfig.vncPort
|
||||
: hostConfig.rdpPort,
|
||||
username:
|
||||
resolvedProtocolForConnect === "vnc"
|
||||
? hostConfig.vncUser
|
||||
: hostConfig.rdpUser,
|
||||
password:
|
||||
resolvedProtocolForConnect === "vnc"
|
||||
? hostConfig.vncPassword
|
||||
: hostConfig.rdpPassword,
|
||||
port: hostConfig.port,
|
||||
username: hostConfig.username,
|
||||
password: hostConfig.password,
|
||||
domain: hostConfig.domain,
|
||||
ignoreCert: true,
|
||||
guacamoleConfig: parseGuacamoleConfig(hostConfig.guacamoleConfig),
|
||||
guacamoleConfig: hostConfig.guacamoleConfig,
|
||||
},
|
||||
resolvedOrigin,
|
||||
)
|
||||
@@ -375,6 +362,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
protocol,
|
||||
promptedCredentials ?? undefined,
|
||||
hostConfig.syncId,
|
||||
tabId,
|
||||
);
|
||||
if (result) {
|
||||
setSessionLookup(
|
||||
@@ -397,6 +385,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
promptedCredentials,
|
||||
resolvedProtocolForConnect,
|
||||
hostConfig,
|
||||
tabId,
|
||||
addLog,
|
||||
t,
|
||||
]);
|
||||
@@ -407,10 +396,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
await fetchToken();
|
||||
tokenRetryRef.current.markConnected();
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(
|
||||
err,
|
||||
t("remoteDesktop.failedToConnect"),
|
||||
);
|
||||
const message = errorMessage(err, t("remoteDesktop.failedToConnect"));
|
||||
setError(message || t("remoteDesktop.failedToConnect"));
|
||||
addLog({ type: "error", stage: "error", message });
|
||||
tokenRetryRef.current.markFailed();
|
||||
@@ -632,7 +618,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
onClose={() => setFileBrowserOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{hostConfig.enableTerminalToolbar !== false && !toolbarHidden && (
|
||||
{hostConfig.showToolbar !== false && !toolbarHidden && (
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol={resolvedProtocol}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import type React from "react";
|
||||
import {
|
||||
useEffect,
|
||||
@@ -11,15 +10,18 @@ import {
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { Upload } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { isElectron } from "@/main-axios";
|
||||
import { getGuacamoleToken } from "./guacamole-api";
|
||||
import { getBasePath } from "@/lib/base-path.ts";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
|
||||
import {
|
||||
resolveConnectionOrigin,
|
||||
buildOriginWsUrl,
|
||||
getBasePath,
|
||||
guacStateToStage,
|
||||
isElectron,
|
||||
resolveConnectionOrigin,
|
||||
type ConnectionOrigin,
|
||||
} from "@/lib/connection-origin.ts";
|
||||
type ConnectionStage,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { errorMessage } from "./host-remote";
|
||||
import { isPasteShortcut, pasteTextToRemote } from "./guacamole-clipboard.ts";
|
||||
import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts";
|
||||
import { bindPointerInput } from "./guacamole-pointer.ts";
|
||||
@@ -31,8 +33,6 @@ import {
|
||||
uploadFileToClient,
|
||||
type GuacamoleFileStreamClient,
|
||||
} from "./guacamole-filesystem.ts";
|
||||
import { guacStateToStage } from "@/components/connection/connection-status.ts";
|
||||
import type { ConnectionStage } from "@/types/connection-log.ts";
|
||||
import { clampGuacamoleZoom, stepGuacamoleZoom } from "./guacamole-zoom.ts";
|
||||
|
||||
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
|
||||
@@ -243,10 +243,10 @@ export const GuacamoleDisplay = forwardRef<
|
||||
// Resolved before the token is minted, not just before the socket is
|
||||
// opened: the token has to come from whichever backend will serve the
|
||||
// session, so both steps have to agree on the origin.
|
||||
const origin = await resolveConnectionOrigin({
|
||||
connectionType: connectionProtocol,
|
||||
connectionOrigin: connectionConfig.connectionOrigin,
|
||||
});
|
||||
const origin = await resolveConnectionOrigin(
|
||||
{ connectionOrigin: connectionConfig.connectionOrigin },
|
||||
{ defaultRemote: true },
|
||||
);
|
||||
|
||||
if (connectionConfig.token) {
|
||||
token = connectionConfig.token;
|
||||
@@ -317,8 +317,8 @@ export const GuacamoleDisplay = forwardRef<
|
||||
if (displaySize.dpi) params.set("dpi", String(displaySize.dpi));
|
||||
return { url: wsBase, query: params.toString() };
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
onError?.(errorMessage);
|
||||
const message = errorMessage(error);
|
||||
onError?.(message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
@@ -698,7 +698,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
if (!isMountedRef.current) return;
|
||||
setIsReady(false);
|
||||
setHasError(true);
|
||||
onError?.(getErrorMessage(error, t("remoteDesktop.connectionError")));
|
||||
onError?.(errorMessage(error, t("remoteDesktop.connectionError")));
|
||||
}
|
||||
}, [
|
||||
getWebSocketConnection,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { errorMessage } from "./host-remote";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
downloadFile,
|
||||
listDirectory,
|
||||
@@ -56,7 +56,7 @@ export function GuacamoleFileBrowser({
|
||||
setEntries(await listDirectory(filesystem, target));
|
||||
} catch (err) {
|
||||
setEntries([]);
|
||||
setError(getErrorMessage(err, t("remoteDesktop.files.listFailed")));
|
||||
setError(errorMessage(err, t("remoteDesktop.files.listFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -26,14 +26,14 @@ import {
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/tooltip.tsx";
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type {
|
||||
GuacamoleDisplayHandle,
|
||||
GuacamoleTouchMode,
|
||||
} from "./GuacamoleDisplay.tsx";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { ActionSlot } from "@termix/plugin-sdk/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
detectRuntimeMetaKeyFamily,
|
||||
metaKeyLabels,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Input } from "@/components/input";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import {
|
||||
FakeSwitch,
|
||||
Input,
|
||||
PasswordInput,
|
||||
SectionCard,
|
||||
Select2,
|
||||
SettingRow,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
Activity,
|
||||
Copy,
|
||||
@@ -16,13 +20,10 @@ import {
|
||||
Zap,
|
||||
Cpu,
|
||||
} from "lucide-react";
|
||||
import type { HostEditorForm } from "@/sidebar/HostEditorData";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import type { RemoteDesktopForm, RemoteFormSetField } from "./remote-form";
|
||||
|
||||
type HostEditorSetField = <K extends keyof HostEditorForm>(
|
||||
key: K,
|
||||
value: HostEditorForm[K],
|
||||
) => void;
|
||||
type HostEditorForm = RemoteDesktopForm;
|
||||
type HostEditorSetField = RemoteFormSetField;
|
||||
|
||||
type GuacFieldSetter = (key: string, value: unknown) => void;
|
||||
|
||||
@@ -49,7 +50,7 @@ export function HostEditorRdpTab({
|
||||
form: HostEditorForm;
|
||||
setField: HostEditorSetField;
|
||||
setGuacField: GuacFieldSetter;
|
||||
host?: Host | null;
|
||||
host?: { macAddress?: string | null } | null;
|
||||
credentials?: { id: string; name: string; username: string }[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -984,7 +985,7 @@ export function HostEditorVncTab({
|
||||
form: HostEditorForm;
|
||||
setField: HostEditorSetField;
|
||||
setGuacField: GuacFieldSetter;
|
||||
host?: Host | null;
|
||||
host?: { macAddress?: string | null } | null;
|
||||
credentials?: { id: string; name: string; username: string }[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -1523,7 +1524,6 @@ export function HostEditorTelnetTab({
|
||||
form: HostEditorForm;
|
||||
setField: HostEditorSetField;
|
||||
setGuacField: GuacFieldSetter;
|
||||
host?: Host | null;
|
||||
credentials?: { id: string; name: string; username: string }[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import type { PluginApiClient, TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
authApi,
|
||||
getRemoteGuacamoleApi,
|
||||
handleApiError,
|
||||
isElectron,
|
||||
} from "@/main-axios";
|
||||
import type { AxiosInstance } from "axios";
|
||||
import type { GuacamoleConfig } from "@/types/guacamole-config";
|
||||
import { resolveRemoteHostId } from "@/lib/remote-server-api";
|
||||
import type { ConnectionOrigin } from "@/lib/connection-origin";
|
||||
resolveRemoteHostId,
|
||||
type ConnectionOrigin,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { GuacamoleConfig } from "./guacamole-config";
|
||||
import { errorMessage } from "./host-remote";
|
||||
|
||||
let app: Pick<TermixApp, "apiFor"> | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setRemoteDesktopApp(next: Pick<TermixApp, "apiFor"> | null) {
|
||||
app = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the backend that will actually serve this session, so the token and
|
||||
@@ -24,9 +29,13 @@ import type { ConnectionOrigin } from "@/lib/connection-origin";
|
||||
* silently sent a missed call site to the remote server, which fails with a
|
||||
* bare network error on a desktop that has none configured.
|
||||
*/
|
||||
function guacamoleApi(origin: ConnectionOrigin): AxiosInstance {
|
||||
if (!isElectron()) return authApi;
|
||||
return origin === "local" ? authApi : getRemoteGuacamoleApi();
|
||||
function guacamoleApi(origin: ConnectionOrigin): PluginApiClient {
|
||||
if (!app) throw new Error("Remote Desktop is not active");
|
||||
return app.apiFor(origin);
|
||||
}
|
||||
|
||||
function apiError(error: unknown, action: string): Error {
|
||||
return new Error(errorMessage(error, `Failed to ${action}`));
|
||||
}
|
||||
|
||||
export interface GuacamoleTokenRequest {
|
||||
@@ -51,18 +60,6 @@ type GuacamoleConfigSource = {
|
||||
guacamoleConfig?: string | Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function parseGuacamoleConfig(
|
||||
config?: string | GuacamoleConfig | null,
|
||||
): GuacamoleConfig {
|
||||
if (!config) return {};
|
||||
if (typeof config !== "string") return config;
|
||||
try {
|
||||
return JSON.parse(config) as GuacamoleConfig;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function getGuacamoleDpi(
|
||||
source?: GuacamoleConfigSource,
|
||||
): number | undefined {
|
||||
@@ -174,8 +171,8 @@ export async function getGuacamoleToken(
|
||||
try {
|
||||
const guacParams = toGuacamoleParams(request.guacamoleConfig);
|
||||
|
||||
const response = await guacamoleApi(origin).post(
|
||||
"/plugin-api/remote-desktop/token",
|
||||
const response = await guacamoleApi(origin).post<GuacamoleTokenResponse>(
|
||||
"/token",
|
||||
{
|
||||
type: request.protocol,
|
||||
hostname: request.hostname,
|
||||
@@ -190,7 +187,7 @@ export async function getGuacamoleToken(
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get guacamole token");
|
||||
throw apiError(error, "get a connection token");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +201,7 @@ export async function getGuacamoleTokenFromHost(
|
||||
domain?: string;
|
||||
},
|
||||
syncId?: string | null,
|
||||
tabInstanceId?: string,
|
||||
): Promise<GuacamoleTokenResponse> {
|
||||
try {
|
||||
// A locally-originated session is served by the embedded backend, which
|
||||
@@ -216,10 +214,11 @@ export async function getGuacamoleTokenFromHost(
|
||||
throw new Error("The synced host does not exist on the remote server");
|
||||
}
|
||||
const targetHostId = remoteHostId ?? hostId;
|
||||
const response = await guacamoleApi(origin).post(
|
||||
`/plugin-api/remote-desktop/connect-host/${targetHostId}`,
|
||||
const response = await guacamoleApi(origin).post<GuacamoleTokenResponse>(
|
||||
`/connect-host/${targetHostId}`,
|
||||
{
|
||||
...(protocol ? { protocol } : {}),
|
||||
...(tabInstanceId ? { tabInstanceId } : {}),
|
||||
...(promptedCredentials?.username
|
||||
? { promptedUsername: promptedCredentials.username }
|
||||
: {}),
|
||||
@@ -233,15 +232,22 @@ export async function getGuacamoleTokenFromHost(
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get guacamole token from host");
|
||||
throw apiError(error, "get a connection token");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGuacdStatus(origin: ConnectionOrigin): Promise<{
|
||||
export interface RemoteDesktopStatus {
|
||||
enabled: boolean;
|
||||
guacd: { status: string };
|
||||
}> {
|
||||
const response = await guacamoleApi(origin).get(
|
||||
"/plugin-api/remote-desktop/status",
|
||||
}
|
||||
|
||||
/** `probe: false` skips dialing guacd, for a caller that only needs `enabled`. */
|
||||
export async function getGuacdStatus(
|
||||
origin: ConnectionOrigin,
|
||||
options: { probe?: boolean } = {},
|
||||
): Promise<RemoteDesktopStatus> {
|
||||
const response = await guacamoleApi(origin).get<RemoteDesktopStatus>(
|
||||
options.probe === false ? "/status?probe=0" : "/status",
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -251,9 +257,34 @@ export async function getGuacamoleConnectionId(
|
||||
origin: ConnectionOrigin,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | null> {
|
||||
const response = await guacamoleApi(origin).get(
|
||||
`/plugin-api/remote-desktop/connection/${encodeURIComponent(connectId)}`,
|
||||
{ signal },
|
||||
);
|
||||
const response = await guacamoleApi(origin).get<{
|
||||
guacamoleConnectionId?: string | null;
|
||||
}>(`/connection/${encodeURIComponent(connectId)}`, { signal });
|
||||
return response.data.guacamoleConnectionId ?? null;
|
||||
}
|
||||
|
||||
export async function nativeRdpAvailable(): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
try {
|
||||
const response = await guacamoleApi("local").get<{ available: boolean }>(
|
||||
"/native-rdp",
|
||||
);
|
||||
return response.data.available === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Always the embedded backend: it is the one that can reach Electron. */
|
||||
export async function openNativeRdp(request: {
|
||||
host: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
domain?: string;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const response = await guacamoleApi("local").post<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>("/native-rdp", request);
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Per-host Guacamole connection settings, as edited in the host editor and
|
||||
* forwarded to guacd. Field names are camelCase here and translated to the
|
||||
* hyphenated protocol parameters in toGuacamoleParams().
|
||||
*/
|
||||
export interface GuacamoleConfig {
|
||||
colorDepth?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpi?: number;
|
||||
resizeMethod?: string;
|
||||
forceLossless?: boolean;
|
||||
disableAudio?: boolean;
|
||||
enableAudioInput?: boolean;
|
||||
enableWallpaper?: boolean;
|
||||
enableTheming?: boolean;
|
||||
enableFontSmoothing?: boolean;
|
||||
enableFullWindowDrag?: boolean;
|
||||
enableDesktopComposition?: boolean;
|
||||
enableMenuAnimations?: boolean;
|
||||
disableBitmapCaching?: boolean;
|
||||
disableOffscreenCaching?: boolean;
|
||||
disableGlyphCaching?: boolean;
|
||||
disableGfx?: boolean;
|
||||
enablePrinting?: boolean;
|
||||
printerName?: string;
|
||||
enableDrive?: boolean;
|
||||
driveName?: string;
|
||||
drivePath?: string;
|
||||
createDrivePath?: boolean;
|
||||
disableDownload?: boolean;
|
||||
disableUpload?: boolean;
|
||||
enableTouch?: boolean;
|
||||
clientName?: string;
|
||||
console?: boolean;
|
||||
initialProgram?: string;
|
||||
serverLayout?: string;
|
||||
timezone?: string;
|
||||
gatewayHostname?: string;
|
||||
gatewayPort?: number;
|
||||
gatewayUsername?: string;
|
||||
gatewayPassword?: string;
|
||||
gatewayDomain?: string;
|
||||
remoteApp?: string;
|
||||
remoteAppDir?: string;
|
||||
remoteAppArgs?: string;
|
||||
normalizeClipboard?: string;
|
||||
disableCopy?: boolean;
|
||||
disablePaste?: boolean;
|
||||
cursor?: string;
|
||||
swapRedBlue?: boolean;
|
||||
readOnly?: boolean;
|
||||
recordingPath?: string;
|
||||
recordingName?: string;
|
||||
createRecordingPath?: boolean;
|
||||
recordingExcludeOutput?: boolean;
|
||||
recordingExcludeMouse?: boolean;
|
||||
recordingIncludeKeys?: boolean;
|
||||
wolSendPacket?: boolean;
|
||||
wolMacAddr?: string;
|
||||
wolBroadcastAddr?: string;
|
||||
wolUdpPort?: number;
|
||||
wolWaitTime?: number;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getGuacamoleConnectionId } from "./guacamole-api";
|
||||
import type { ConnectionOrigin } from "@/lib/connection-origin";
|
||||
import type { ConnectionOrigin } from "@termix/plugin-sdk/ui";
|
||||
|
||||
export function watchGuacamoleConnectionId(
|
||||
connectId: string,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
|
||||
import type { GuacamoleConfig } from "./guacamole-config";
|
||||
|
||||
export const PLUGIN_ID = "remote-desktop";
|
||||
|
||||
export type Protocol = "rdp" | "vnc" | "telnet";
|
||||
|
||||
/** This plugin's host settings, as they arrive on host.pluginSettings. */
|
||||
export interface RemoteHostOptions {
|
||||
enableRdp: boolean;
|
||||
enableVnc: boolean;
|
||||
enableTelnet: boolean;
|
||||
rdpPort: number;
|
||||
vncPort: number;
|
||||
telnetPort: number;
|
||||
rdpSecurity: string;
|
||||
rdpIgnoreCert: boolean;
|
||||
guacamoleConfig: GuacamoleConfig;
|
||||
enableToolbar: boolean;
|
||||
}
|
||||
|
||||
export const ENABLE_KEY = {
|
||||
rdp: "enableRdp",
|
||||
vnc: "enableVnc",
|
||||
telnet: "enableTelnet",
|
||||
} as const satisfies Record<Protocol, keyof RemoteHostOptions>;
|
||||
|
||||
export const PORT_KEY = {
|
||||
rdp: "rdpPort",
|
||||
vnc: "vncPort",
|
||||
telnet: "telnetPort",
|
||||
} as const satisfies Record<Protocol, keyof RemoteHostOptions>;
|
||||
|
||||
export const DEFAULT_PORT: Record<Protocol, number> = {
|
||||
rdp: 3389,
|
||||
vnc: 5900,
|
||||
telnet: 23,
|
||||
};
|
||||
|
||||
/** The core fields holding a protocol's login, which stay on the host. */
|
||||
export interface RemoteHostLogin {
|
||||
id?: number | string;
|
||||
name?: string | null;
|
||||
ip?: string;
|
||||
domain?: string;
|
||||
syncId?: string | null;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
rdpAuthType?: string;
|
||||
rdpUser?: string;
|
||||
rdpPassword?: string;
|
||||
vncUser?: string;
|
||||
vncPassword?: string;
|
||||
authOverrides?: Record<
|
||||
string,
|
||||
{
|
||||
credentialId?: number | string | null;
|
||||
required?: boolean;
|
||||
ownerAuthShared?: boolean;
|
||||
}
|
||||
>;
|
||||
pluginSettings?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function port(value: unknown, fallback: number): number {
|
||||
const parsed = typeof value === "string" ? Number(value) : value;
|
||||
return typeof parsed === "number" && Number.isInteger(parsed) && parsed > 0
|
||||
? parsed
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function parseGuacamoleConfig(value: unknown): GuacamoleConfig {
|
||||
if (!value) return {};
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return typeof value === "object" ? (value as GuacamoleConfig) : {};
|
||||
}
|
||||
|
||||
export function remoteOptions(
|
||||
settings: Record<string, unknown> | undefined,
|
||||
): RemoteHostOptions {
|
||||
const values = settings ?? {};
|
||||
return {
|
||||
enableRdp: values.enableRdp === true,
|
||||
enableVnc: values.enableVnc === true,
|
||||
enableTelnet: values.enableTelnet === true,
|
||||
rdpPort: port(values.rdpPort, DEFAULT_PORT.rdp),
|
||||
vncPort: port(values.vncPort, DEFAULT_PORT.vnc),
|
||||
telnetPort: port(values.telnetPort, DEFAULT_PORT.telnet),
|
||||
rdpSecurity:
|
||||
typeof values.rdpSecurity === "string" ? values.rdpSecurity : "",
|
||||
rdpIgnoreCert: values.rdpIgnoreCert === true,
|
||||
guacamoleConfig: parseGuacamoleConfig(values.guacamoleConfig),
|
||||
enableToolbar: values.enableToolbar !== false,
|
||||
};
|
||||
}
|
||||
|
||||
export function hostRemoteOptions(
|
||||
host:
|
||||
| Pick<RemoteHostLogin, "pluginSettings">
|
||||
| PluginHostRecord
|
||||
| null
|
||||
| undefined,
|
||||
): RemoteHostOptions {
|
||||
const bag = (host as RemoteHostLogin | null | undefined)?.pluginSettings;
|
||||
return remoteOptions(bag?.[PLUGIN_ID]);
|
||||
}
|
||||
|
||||
export function protocolEnabled(
|
||||
host:
|
||||
| Pick<RemoteHostLogin, "pluginSettings">
|
||||
| PluginHostRecord
|
||||
| null
|
||||
| undefined,
|
||||
protocol: Protocol,
|
||||
): boolean {
|
||||
return hostRemoteOptions(host)[ENABLE_KEY[protocol]];
|
||||
}
|
||||
|
||||
/** Quick Connect hosts are never saved; their ids carry this prefix. */
|
||||
const QUICK_CONNECT_ID_PREFIX = "quick-connect-";
|
||||
|
||||
export function isQuickConnectHost(host: { id?: unknown } | null | undefined) {
|
||||
return String(host?.id ?? "").startsWith(QUICK_CONNECT_ID_PREFIX);
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown, fallback = "Unknown error") {
|
||||
const data = (error as { response?: { data?: { error?: unknown } } })
|
||||
?.response?.data?.error;
|
||||
if (typeof data === "string" && data) return data;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === "string" && error) return error;
|
||||
return fallback;
|
||||
}
|
||||
@@ -1,23 +1,21 @@
|
||||
import type { ComponentType, Ref } from "react";
|
||||
import i18next from "i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Info,
|
||||
MessagesSquare,
|
||||
Monitor,
|
||||
MonitorUp,
|
||||
MousePointerClick,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useTranslation,
|
||||
type HostEditorSectionProps,
|
||||
type PluginHostRecord,
|
||||
type StandaloneViewProps,
|
||||
type TabProps,
|
||||
type TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { FakeSwitch, SectionCard, SettingRow } from "@termix/plugin-sdk/ui";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { isQuickConnectHost } from "@/sidebar/quick-connect-host";
|
||||
import { SectionCard, isElectron } from "@termix/plugin-sdk/ui";
|
||||
import { toast } from "sonner";
|
||||
import GuacamoleApp, { type GuacamoleAppHandle } from "./GuacamoleApp";
|
||||
import { GuacamoleDisplay } from "./GuacamoleDisplay";
|
||||
import {
|
||||
@@ -25,48 +23,70 @@ import {
|
||||
HostEditorTelnetTab,
|
||||
HostEditorVncTab,
|
||||
} from "./HostEditorGuacamoleTabs";
|
||||
import { getGuacamoleTokenFromHost } from "./guacamole-api";
|
||||
import {
|
||||
getGuacamoleTokenFromHost,
|
||||
getGuacdStatus,
|
||||
nativeRdpAvailable,
|
||||
openNativeRdp,
|
||||
setRemoteDesktopApp,
|
||||
} from "./guacamole-api";
|
||||
import { quickConnectGuacHost } from "./quick-connect-guac-host";
|
||||
import { REMOTE_DESKTOP_TOOLBAR_SLOT } from "./GuacamoleToolbar.tsx";
|
||||
|
||||
type Protocol = "rdp" | "vnc" | "telnet";
|
||||
import {
|
||||
DEFAULT_PORT,
|
||||
ENABLE_KEY,
|
||||
PORT_KEY,
|
||||
hostRemoteOptions,
|
||||
isQuickConnectHost,
|
||||
protocolEnabled,
|
||||
type Protocol,
|
||||
type RemoteHostLogin,
|
||||
} from "./host-remote";
|
||||
import { remoteDesktopForm } from "./remote-form";
|
||||
|
||||
const PROTOCOLS: {
|
||||
id: Protocol;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
paletteKey: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
enableKey: "enableRdp" | "enableVnc" | "enableTelnet";
|
||||
priority: number;
|
||||
order: number;
|
||||
quickConnect?: { showDomain?: boolean };
|
||||
}[] = [
|
||||
{
|
||||
id: "rdp",
|
||||
titleKey: "hosts.tabRdp",
|
||||
descriptionKey: "hosts.remoteDesktop",
|
||||
paletteKey: "palette.connectRdp",
|
||||
icon: Monitor,
|
||||
enableKey: "enableRdp",
|
||||
priority: 50,
|
||||
order: 100,
|
||||
quickConnect: { showDomain: true },
|
||||
},
|
||||
{
|
||||
id: "vnc",
|
||||
titleKey: "hosts.tabVnc",
|
||||
descriptionKey: "hosts.virtualNetwork",
|
||||
paletteKey: "palette.connectVnc",
|
||||
icon: MousePointerClick,
|
||||
enableKey: "enableVnc",
|
||||
priority: 40,
|
||||
order: 110,
|
||||
quickConnect: {},
|
||||
},
|
||||
{
|
||||
id: "telnet",
|
||||
titleKey: "hosts.tabTelnet",
|
||||
descriptionKey: "hosts.unencryptedShell",
|
||||
paletteKey: "palette.connectTelnet",
|
||||
icon: MessagesSquare,
|
||||
enableKey: "enableTelnet",
|
||||
priority: 30,
|
||||
order: 120,
|
||||
},
|
||||
];
|
||||
|
||||
function RemoteDesktopTab({ tab, host, isVisible, handleRef }: TabProps) {
|
||||
const shellHost = host as unknown as Host;
|
||||
const record = host as unknown as RemoteHostLogin | undefined;
|
||||
return (
|
||||
<GuacamoleApp
|
||||
ref={handleRef as Ref<GuacamoleAppHandle>}
|
||||
@@ -75,8 +95,8 @@ function RemoteDesktopTab({ tab, host, isVisible, handleRef }: TabProps) {
|
||||
protocol={tab.type as Protocol}
|
||||
isVisible={isVisible}
|
||||
quickConnectHost={
|
||||
isQuickConnectHost(shellHost)
|
||||
? quickConnectGuacHost(shellHost)
|
||||
record && isQuickConnectHost(record)
|
||||
? quickConnectGuacHost(record)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -111,79 +131,65 @@ function RemoteDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
/** Applies one remote desktop field and stops inheriting user defaults. */
|
||||
function useGuacFieldSetter(
|
||||
updateForm: HostEditorSectionProps["updateForm"],
|
||||
): (key: string, value: unknown) => void {
|
||||
return (key, value) =>
|
||||
updateForm((current) => ({
|
||||
inheritRemoteDesktopDefaults: false,
|
||||
guacamoleConfig: {
|
||||
...((current.guacamoleConfig as Record<string, unknown>) ?? {}),
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
function SectionNotes({ protocol }: { protocol: Protocol }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("hosts.remoteDesktopNotes")}
|
||||
icon={<Info className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-2 py-3 text-xs text-muted-foreground">
|
||||
{protocol === "rdp" && <p>{t("hosts.userDefaultsNote")}</p>}
|
||||
{isElectron() && <p>{t("hosts.connectionOriginNote")}</p>}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function RdpSection(props: HostEditorSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const setGuacField = useGuacFieldSetter(props.updateForm);
|
||||
const { form, setField, setGuacField } = remoteDesktopForm(props);
|
||||
return (
|
||||
<>
|
||||
<SectionCard
|
||||
title={t("hosts.rdpDefaults", { defaultValue: "RDP defaults" })}
|
||||
icon={<Settings className="size-3.5" />}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("hosts.useUserDefaults", {
|
||||
defaultValue: "Use user defaults",
|
||||
})}
|
||||
description={t("hosts.useUserRdpDefaultsDesc", {
|
||||
defaultValue:
|
||||
"Inherit performance, redirection, and clipboard settings from User Profile.",
|
||||
})}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={!!props.form.inheritRemoteDesktopDefaults}
|
||||
onChange={(value) =>
|
||||
props.setField("inheritRemoteDesktopDefaults", value)
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
<HostEditorRdpTab
|
||||
form={props.form}
|
||||
setField={props.setField as never}
|
||||
setGuacField={setGuacField as never}
|
||||
host={props.host as unknown as Host}
|
||||
form={form}
|
||||
setField={setField}
|
||||
setGuacField={setGuacField}
|
||||
host={props.host as { macAddress?: string | null } | undefined}
|
||||
credentials={props.credentials as never}
|
||||
/>
|
||||
<SectionNotes protocol="rdp" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VncSection(props: HostEditorSectionProps) {
|
||||
const setGuacField = useGuacFieldSetter(props.updateForm);
|
||||
const { form, setField, setGuacField } = remoteDesktopForm(props);
|
||||
return (
|
||||
<HostEditorVncTab
|
||||
form={props.form}
|
||||
setField={props.setField as never}
|
||||
setGuacField={setGuacField as never}
|
||||
host={props.host as unknown as Host}
|
||||
credentials={props.credentials as never}
|
||||
/>
|
||||
<>
|
||||
<HostEditorVncTab
|
||||
form={form}
|
||||
setField={setField}
|
||||
setGuacField={setGuacField}
|
||||
host={props.host as { macAddress?: string | null } | undefined}
|
||||
credentials={props.credentials as never}
|
||||
/>
|
||||
<SectionNotes protocol="vnc" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TelnetSection(props: HostEditorSectionProps) {
|
||||
const setGuacField = useGuacFieldSetter(props.updateForm);
|
||||
const { form, setField, setGuacField } = remoteDesktopForm(props);
|
||||
return (
|
||||
<HostEditorTelnetTab
|
||||
form={props.form}
|
||||
setField={props.setField as never}
|
||||
setGuacField={setGuacField as never}
|
||||
credentials={props.credentials as never}
|
||||
/>
|
||||
<>
|
||||
<HostEditorTelnetTab
|
||||
form={form}
|
||||
setField={setField}
|
||||
setGuacField={setGuacField}
|
||||
credentials={props.credentials as never}
|
||||
/>
|
||||
<SectionNotes protocol="telnet" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,23 +199,96 @@ const SECTIONS: Record<Protocol, ComponentType<HostEditorSectionProps>> = {
|
||||
telnet: TelnetSection,
|
||||
};
|
||||
|
||||
async function openNativeRdp(host: Record<string, unknown>): Promise<void> {
|
||||
const t = (key: string) => i18next.t(`remote-desktop:${key}`);
|
||||
try {
|
||||
const result = await window.electronAPI.openNativeRdp({
|
||||
host: String(host.ip),
|
||||
port: (host.rdpPort as number | undefined) ?? 3389,
|
||||
username: host.rdpUser as string | undefined,
|
||||
domain: host.domain as string | undefined,
|
||||
function registerNativeRdp(app: TermixApp): void {
|
||||
let disposed = false;
|
||||
app.onDispose(() => {
|
||||
disposed = true;
|
||||
});
|
||||
void nativeRdpAvailable().then((available) => {
|
||||
if (disposed || !available) return;
|
||||
app.registerHostAction({
|
||||
id: "rdp-native",
|
||||
titleKey: "hosts.openNativeRdp",
|
||||
icon: MonitorUp,
|
||||
kind: "open",
|
||||
order: 105,
|
||||
when: (host) => protocolEnabled(host, "rdp"),
|
||||
run: (host) => {
|
||||
const record = host as unknown as RemoteHostLogin;
|
||||
void openNativeRdp({
|
||||
host: String(record.ip ?? ""),
|
||||
port: hostRemoteOptions(record).rdpPort,
|
||||
username: record.rdpUser,
|
||||
domain: record.domain,
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.success) toast.success(app.t("hosts.nativeRdpOpened"));
|
||||
else toast.error(result.error || app.t("hosts.nativeRdpFailed"));
|
||||
})
|
||||
.catch(() => toast.error(app.t("hosts.nativeRdpFailed")));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function registerHostSurfaces(app: TermixApp): void {
|
||||
for (const protocol of PROTOCOLS) {
|
||||
const when = (host: PluginHostRecord) => protocolEnabled(host, protocol.id);
|
||||
|
||||
app.registerHostProtocol({
|
||||
id: protocol.id,
|
||||
settingKey: ENABLE_KEY[protocol.id],
|
||||
portKey: PORT_KEY[protocol.id],
|
||||
defaultPort: DEFAULT_PORT[protocol.id],
|
||||
titleKey: protocol.titleKey,
|
||||
descriptionKey: protocol.descriptionKey,
|
||||
icon: protocol.icon,
|
||||
order: protocol.order,
|
||||
quickConnect: protocol.quickConnect,
|
||||
});
|
||||
|
||||
app.registerHostAction({
|
||||
id: protocol.id,
|
||||
titleKey: protocol.titleKey,
|
||||
icon: protocol.icon,
|
||||
kind: "connect",
|
||||
priority: protocol.priority,
|
||||
order: protocol.order,
|
||||
tabType: protocol.id,
|
||||
copyUrlView: protocol.id,
|
||||
when,
|
||||
});
|
||||
|
||||
app.registerPaletteEntry({
|
||||
id: `remote-desktop.${protocol.id}`,
|
||||
titleKey: protocol.paletteKey,
|
||||
icon: protocol.icon,
|
||||
keywords: [protocol.id],
|
||||
scope: "host",
|
||||
when: (host) => !!host && when(host),
|
||||
run: (shell, host) => {
|
||||
if (host) shell.openTab(host, protocol.id);
|
||||
},
|
||||
});
|
||||
|
||||
app.registerHostEditorSection({
|
||||
id: protocol.id,
|
||||
group: "top",
|
||||
titleKey: protocol.titleKey,
|
||||
icon: protocol.icon,
|
||||
order: protocol.order / 5,
|
||||
visible: (protocols) => !!protocols[ENABLE_KEY[protocol.id]],
|
||||
component: SECTIONS[protocol.id],
|
||||
});
|
||||
if (result.success) toast.success(t("hosts.nativeRdpOpened"));
|
||||
else toast.error(result.error || t("hosts.nativeRdpFailed"));
|
||||
} catch {
|
||||
toast.error(t("hosts.nativeRdpFailed"));
|
||||
}
|
||||
|
||||
if (isElectron()) registerNativeRdp(app);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
setRemoteDesktopApp(app);
|
||||
app.onDispose(() => setRemoteDesktopApp(null));
|
||||
|
||||
// Guest pages only draw shared streams.
|
||||
app.registerSlotContribution("session.remoteDisplay", {
|
||||
actionId: "remote-desktop.display",
|
||||
@@ -253,57 +332,24 @@ export function activate(app: TermixApp): void {
|
||||
noHostMessageKey: "remoteDesktop.noHostSelected",
|
||||
persistent: true,
|
||||
session: true,
|
||||
restore: (host) => !!host[protocol.enableKey],
|
||||
restore: (host) => protocolEnabled(host, protocol.id),
|
||||
activityTypes: [protocol.id],
|
||||
standalone: RemoteDesktopStandalone,
|
||||
preload: () => import("./GuacamoleApp"),
|
||||
});
|
||||
|
||||
app.registerHostAction({
|
||||
id: protocol.id,
|
||||
titleKey: protocol.titleKey,
|
||||
icon: protocol.icon,
|
||||
kind: "connect",
|
||||
priority: protocol.priority,
|
||||
order: protocol.order,
|
||||
tabType: protocol.id,
|
||||
copyUrlView: protocol.id,
|
||||
when: (host) => !!host[protocol.enableKey],
|
||||
});
|
||||
|
||||
app.registerHostEditorSection({
|
||||
id: protocol.id,
|
||||
group: "top",
|
||||
titleKey: protocol.titleKey,
|
||||
icon: protocol.icon,
|
||||
order: protocol.order / 5,
|
||||
visible: (protocols) => !!protocols[protocol.enableKey],
|
||||
component: SECTIONS[protocol.id],
|
||||
});
|
||||
}
|
||||
|
||||
// The desktop app on Windows can hand RDP to the system client instead.
|
||||
if (window.electronAPI?.isElectron) {
|
||||
let disposed = false;
|
||||
app.onDispose(() => {
|
||||
disposed = true;
|
||||
// An admin can turn Remote Desktop off; its ways in go with it.
|
||||
let disposed = false;
|
||||
app.onDispose(() => {
|
||||
disposed = true;
|
||||
});
|
||||
void getGuacdStatus("local", { probe: false })
|
||||
.then((status) => status.enabled !== false)
|
||||
.catch(() => true)
|
||||
.then((enabled) => {
|
||||
if (!disposed && enabled) registerHostSurfaces(app);
|
||||
});
|
||||
window.electronAPI
|
||||
.getPlatform()
|
||||
.then((platform: string) => {
|
||||
if (disposed || platform !== "win32") return;
|
||||
app.registerHostAction({
|
||||
id: "rdp-native",
|
||||
titleKey: "hosts.openNativeRdp",
|
||||
icon: MonitorUp,
|
||||
kind: "open",
|
||||
order: 105,
|
||||
when: (host) => !!host.enableRdp,
|
||||
run: (host) => void openNativeRdp(host),
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
app.registerSlotContribution("onboarding.features", {
|
||||
actionId: "remote-desktop.feature",
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { GuacamoleQuickHost } from "./GuacamoleApp";
|
||||
import {
|
||||
hostRemoteOptions,
|
||||
type Protocol,
|
||||
type RemoteHostLogin,
|
||||
} from "./host-remote";
|
||||
|
||||
/** The slice of a quick-connect host that GuacamoleApp mints a token from. */
|
||||
export function quickConnectGuacHost(host: Host): GuacamoleQuickHost {
|
||||
export function quickConnectGuacHost(
|
||||
host: RemoteHostLogin,
|
||||
): GuacamoleQuickHost {
|
||||
const options = hostRemoteOptions(host);
|
||||
const connectionType: Protocol = options.enableVnc ? "vnc" : "rdp";
|
||||
return {
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
connectionType: host.enableVnc ? "vnc" : "rdp",
|
||||
name: host.name ?? undefined,
|
||||
ip: host.ip ?? "",
|
||||
connectionType,
|
||||
domain: host.domain,
|
||||
rdpPort: host.rdpPort,
|
||||
vncPort: host.vncPort,
|
||||
port: connectionType === "vnc" ? options.vncPort : options.rdpPort,
|
||||
rdpAuthType: host.rdpAuthType,
|
||||
rdpUser: host.rdpUser,
|
||||
rdpPassword: host.rdpPassword,
|
||||
vncUser: host.vncUser,
|
||||
vncPassword: host.vncPassword,
|
||||
username: connectionType === "vnc" ? host.vncUser : host.rdpUser,
|
||||
password: connectionType === "vnc" ? host.vncPassword : host.rdpPassword,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HostAuthOverrides } from "@/types/auth-protocols";
|
||||
import type { RemoteHostLogin } from "./host-remote";
|
||||
|
||||
export function needsRdpCredentialPrompt({
|
||||
protocol,
|
||||
@@ -7,7 +7,7 @@ export function needsRdpCredentialPrompt({
|
||||
}: {
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
rdpAuthType?: string;
|
||||
authOverrides?: HostAuthOverrides;
|
||||
authOverrides?: RemoteHostLogin["authOverrides"];
|
||||
}): boolean {
|
||||
return (
|
||||
protocol === "rdp" &&
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { HostEditorSectionProps } from "@termix/plugin-sdk/frontend";
|
||||
import { PLUGIN_ID, remoteOptions } from "./host-remote";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* The host editor fields the RDP, VNC and Telnet tabs edit. The logins are
|
||||
* core host fields; the options (ports, security, guacd settings) are this
|
||||
* plugin's host settings, presented here under the names the tabs use.
|
||||
*/
|
||||
export interface RemoteDesktopForm {
|
||||
domain: string;
|
||||
security: string;
|
||||
ignoreCert: boolean;
|
||||
rdpPort: number;
|
||||
vncPort: number;
|
||||
telnetPort: number;
|
||||
guacamoleConfig: Record<string, any>;
|
||||
rdpAuthType: "direct" | "credential" | "none";
|
||||
rdpCredentialId: string;
|
||||
rdpUser: string;
|
||||
rdpPassword: string;
|
||||
vncAuthType: "direct" | "credential";
|
||||
vncCredentialId: string;
|
||||
vncUser: string;
|
||||
vncPassword: string;
|
||||
telnetAuthType: "direct" | "credential";
|
||||
telnetCredentialId: string;
|
||||
telnetUser: string;
|
||||
telnetPassword: string;
|
||||
statsConfig: Record<string, any>;
|
||||
}
|
||||
|
||||
export type RemoteFormSetField = <K extends keyof RemoteDesktopForm>(
|
||||
key: K,
|
||||
value: RemoteDesktopForm[K],
|
||||
) => void;
|
||||
|
||||
/** Tab field name to this plugin's host setting key. */
|
||||
const SETTING_FOR_FIELD: Partial<Record<keyof RemoteDesktopForm, string>> = {
|
||||
rdpPort: "rdpPort",
|
||||
vncPort: "vncPort",
|
||||
telnetPort: "telnetPort",
|
||||
security: "rdpSecurity",
|
||||
ignoreCert: "rdpIgnoreCert",
|
||||
};
|
||||
|
||||
type PluginSettingsBag = Record<string, Record<string, unknown>>;
|
||||
|
||||
function patchSettings(
|
||||
current: Record<string, unknown>,
|
||||
patch: (settings: Record<string, unknown>) => Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const bag = (current.pluginSettings as PluginSettingsBag | undefined) ?? {};
|
||||
const mine = bag[PLUGIN_ID] ?? {};
|
||||
return {
|
||||
pluginSettings: { ...bag, [PLUGIN_ID]: { ...mine, ...patch(mine) } },
|
||||
};
|
||||
}
|
||||
|
||||
export function remoteDesktopForm(props: HostEditorSectionProps): {
|
||||
form: RemoteDesktopForm;
|
||||
setField: RemoteFormSetField;
|
||||
setGuacField: (key: string, value: unknown) => void;
|
||||
} {
|
||||
const bag = props.form?.pluginSettings as PluginSettingsBag | undefined;
|
||||
const options = remoteOptions(bag?.[PLUGIN_ID]);
|
||||
const form: RemoteDesktopForm = {
|
||||
...(props.form as RemoteDesktopForm),
|
||||
rdpPort: options.rdpPort,
|
||||
vncPort: options.vncPort,
|
||||
telnetPort: options.telnetPort,
|
||||
security: options.rdpSecurity,
|
||||
ignoreCert: options.rdpIgnoreCert,
|
||||
guacamoleConfig: options.guacamoleConfig as Record<string, any>,
|
||||
};
|
||||
|
||||
const setField: RemoteFormSetField = (key, value) => {
|
||||
const setting = SETTING_FOR_FIELD[key];
|
||||
if (!setting) {
|
||||
props.setField(key, value);
|
||||
return;
|
||||
}
|
||||
props.updateForm((current) =>
|
||||
patchSettings(current, () => ({ [setting]: value })),
|
||||
);
|
||||
};
|
||||
|
||||
const setGuacField = (key: string, value: unknown) =>
|
||||
props.updateForm((current) =>
|
||||
patchSettings(current, (mine) => ({
|
||||
guacamoleConfig: {
|
||||
...remoteOptions(mine).guacamoleConfig,
|
||||
[key]: value,
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
return { form, setField, setGuacField };
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeImportedHost,
|
||||
userDefaultParams,
|
||||
} from "../../src/backend/host-settings.js";
|
||||
|
||||
describe("normalizeImportedHost", () => {
|
||||
it("reads a Termix export's pluginSettings", () => {
|
||||
expect(
|
||||
normalizeImportedHost({
|
||||
pluginSettings: {
|
||||
"remote-desktop": {
|
||||
enableVnc: true,
|
||||
vncPort: 5901,
|
||||
guacamoleConfig: { cursor: "local" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
enableVnc: true,
|
||||
vncPort: 5901,
|
||||
guacamoleConfig: { cursor: "local" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the flat fields hosts had before 2.9.0", () => {
|
||||
expect(
|
||||
normalizeImportedHost({
|
||||
enableRdp: true,
|
||||
rdpPort: "3390",
|
||||
security: "nla",
|
||||
ignoreCert: 1,
|
||||
guacamoleConfig: JSON.stringify({ dpi: 120 }),
|
||||
enableTerminalToolbar: false,
|
||||
}),
|
||||
).toEqual({
|
||||
enableRdp: true,
|
||||
rdpPort: 3390,
|
||||
rdpSecurity: "nla",
|
||||
rdpIgnoreCert: true,
|
||||
guacamoleConfig: { dpi: 120 },
|
||||
enableToolbar: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("turns a connectionType-only host into its switch and port", () => {
|
||||
expect(
|
||||
normalizeImportedHost({ connectionType: "telnet", port: 2323 }),
|
||||
).toEqual({ enableTelnet: true, telnetPort: 2323 });
|
||||
});
|
||||
|
||||
it("writes nothing for a plain SSH row", () => {
|
||||
expect(
|
||||
normalizeImportedHost({ connectionType: "ssh", port: 22 }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a port out of range", () => {
|
||||
expect(normalizeImportedHost({ enableRdp: true, rdpPort: 70000 })).toEqual({
|
||||
enableRdp: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("userDefaultParams", () => {
|
||||
it("maps the user settings onto guacd parameters, skipping inherit", () => {
|
||||
expect(
|
||||
userDefaultParams({
|
||||
colorDepth: "24",
|
||||
resizeMethod: "reconnect",
|
||||
disableCopy: "on",
|
||||
enableWallpaper: "off",
|
||||
enableDrive: "inherit",
|
||||
}),
|
||||
).toEqual({
|
||||
"color-depth": 24,
|
||||
"resize-method": "reconnect",
|
||||
"disable-copy": true,
|
||||
"enable-wallpaper": false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRdpSettings,
|
||||
resolveRdpAuthTypeForConnect,
|
||||
resolveRdpDomain,
|
||||
} from "../../src/backend/rdp-settings.js";
|
||||
|
||||
@@ -53,32 +52,3 @@ describe("buildRdpSettings", () => {
|
||||
expect(resolveRdpDomain("none", undefined, "SAVED")).toBe("SAVED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRdpAuthTypeForConnect", () => {
|
||||
it("keeps prompt-on-connect authentication for a secretless recipient", () => {
|
||||
expect(
|
||||
resolveRdpAuthTypeForConnect({
|
||||
storedAuthType: "none",
|
||||
sharedResolution: { source: "secretless" },
|
||||
}),
|
||||
).toBe("none");
|
||||
});
|
||||
|
||||
it("uses a recipient override instead of prompting", () => {
|
||||
expect(
|
||||
resolveRdpAuthTypeForConnect({
|
||||
storedAuthType: "none",
|
||||
sharedResolution: { source: "personal-override" },
|
||||
}),
|
||||
).toBe("credential");
|
||||
});
|
||||
|
||||
it("uses authentication shared by the owner", () => {
|
||||
expect(
|
||||
resolveRdpAuthTypeForConnect({
|
||||
storedAuthType: "credential",
|
||||
sharedResolution: { source: "owner-shared", authType: "credential" },
|
||||
}),
|
||||
).toBe("credential");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express, { type Router } from "express";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMockCtx,
|
||||
type MockPluginContext,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginProtocolTarget,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import { GuacamoleTokenService } from "../../src/backend/token-service.js";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
// The token key comes from JWT_SECRET; without one each service picks its own.
|
||||
process.env.JWT_SECRET = "remote-desktop-test-secret";
|
||||
|
||||
function target(
|
||||
overrides: Partial<PluginProtocolTarget["auth"]> = {},
|
||||
): PluginProtocolTarget {
|
||||
return {
|
||||
host: {
|
||||
id: 7,
|
||||
name: "Win box",
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
ownerUserId: "user-1",
|
||||
jumpHosts: [],
|
||||
},
|
||||
shared: false,
|
||||
auth: {
|
||||
authType: "direct",
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
domain: "CORP",
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Server {
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
let server: Server | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
async function start(
|
||||
options: {
|
||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||
services?: Record<string, object>;
|
||||
capabilities?: string[];
|
||||
} = {},
|
||||
): Promise<Server> {
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: options.capabilities ?? manifest.capabilities,
|
||||
router: () => (router = express.Router()),
|
||||
protocolTargets: options.protocolTargets,
|
||||
services: options.services,
|
||||
});
|
||||
await activate(mock.ctx as PluginContext);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? "user-1");
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
const httpServer = http.createServer(app);
|
||||
await new Promise<void>((resolve) => httpServer.listen(0, resolve));
|
||||
const { port } = httpServer.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
mock,
|
||||
async request(method, path, body) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: body ? { "content-type": "application/json" } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||
for (const dispose of [...mock.disposals].reverse()) await dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const tokens = new GuacamoleTokenService();
|
||||
|
||||
describe("POST /connect-host/:hostId", () => {
|
||||
it("mints a token from the resolved login and the host's own settings", async () => {
|
||||
server = await start({ protocolTargets: { "7:rdp": target() } });
|
||||
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
|
||||
await server.mock.ctx.settings.setHost(7, "rdpPort", 3390);
|
||||
await server.mock.ctx.settings.setHost(7, "rdpSecurity", "nla");
|
||||
await server.mock.ctx.settings.setHost(7, "guacamoleConfig", {
|
||||
"color-depth": 16,
|
||||
"enable-wallpaper": "auto",
|
||||
});
|
||||
|
||||
const response = await server.request("POST", "/connect-host/7", {
|
||||
protocol: "rdp",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.termixConnectId).toEqual(expect.any(String));
|
||||
const token = tokens.decryptToken(response.body.token);
|
||||
expect(token?.connection.settings).toMatchObject({
|
||||
hostname: "10.0.0.7",
|
||||
port: 3390,
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
domain: "CORP",
|
||||
security: "nla",
|
||||
"color-depth": 16,
|
||||
});
|
||||
expect(token?.connection.settings).not.toHaveProperty("enable-wallpaper");
|
||||
expect(token?.termixMeta).toMatchObject({
|
||||
hostId: 7,
|
||||
hostName: "Win box",
|
||||
protocol: "rdp",
|
||||
});
|
||||
expect(server.mock.audits.at(-1)).toMatchObject({
|
||||
action: "rdp_connect",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("puts the user's RDP defaults under the host's own values", async () => {
|
||||
server = await start({ protocolTargets: { "7:rdp": target() } });
|
||||
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
|
||||
await server.mock.ctx.settings.setHost(7, "guacamoleConfig", {
|
||||
"color-depth": 32,
|
||||
});
|
||||
await server.mock.ctx.settings.setUser("user-1", "colorDepth", "16");
|
||||
await server.mock.ctx.settings.setUser("user-1", "disableCopy", "on");
|
||||
|
||||
const response = await server.request("POST", "/connect-host/7", {
|
||||
protocol: "rdp",
|
||||
});
|
||||
const settings = tokens.decryptToken(response.body.token)?.connection
|
||||
.settings;
|
||||
expect(settings?.["color-depth"]).toBe(32);
|
||||
expect(settings?.["disable-copy"]).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the prompted login when the host asks at connect time", async () => {
|
||||
server = await start({
|
||||
protocolTargets: { "7:rdp": target({ authType: "none", username: "" }) },
|
||||
});
|
||||
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
|
||||
|
||||
const response = await server.request("POST", "/connect-host/7", {
|
||||
protocol: "rdp",
|
||||
promptedUsername: "bob",
|
||||
promptedPassword: "pw",
|
||||
promptedDomain: "",
|
||||
});
|
||||
const settings = tokens.decryptToken(response.body.token)?.connection
|
||||
.settings;
|
||||
expect(settings).toMatchObject({ username: "bob", password: "pw" });
|
||||
});
|
||||
|
||||
it("refuses a protocol the host has switched off", async () => {
|
||||
server = await start({ protocolTargets: { "7:vnc": target() } });
|
||||
const response = await server.request("POST", "/connect-host/7", {
|
||||
protocol: "vnc",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("answers 404 when the caller cannot reach the host", async () => {
|
||||
server = await start();
|
||||
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
|
||||
const response = await server.request("POST", "/connect-host/7", {
|
||||
protocol: "rdp",
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("refuses everything while an admin has turned it off", async () => {
|
||||
server = await start({ protocolTargets: { "7:rdp": target() } });
|
||||
await server.mock.ctx.settings.set("enabled", false);
|
||||
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
|
||||
|
||||
expect(
|
||||
(await server.request("POST", "/connect-host/7", { protocol: "rdp" }))
|
||||
.status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/token", {
|
||||
type: "rdp",
|
||||
hostname: "10.0.0.9",
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
const status = await server.request("GET", "/status?probe=0");
|
||||
expect(status.body.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("records only when session recording is on for the host", async () => {
|
||||
const enabledFor = async (hostId: number) => hostId === 7;
|
||||
server = await start({
|
||||
protocolTargets: { "7:rdp": target() },
|
||||
services: {
|
||||
"recordings.writer": {
|
||||
enabledFor,
|
||||
createFinished: async () => ({ id: 1 }),
|
||||
},
|
||||
},
|
||||
});
|
||||
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
|
||||
const response = await server.request("POST", "/connect-host/7", {
|
||||
protocol: "rdp",
|
||||
});
|
||||
const token = tokens.decryptToken(response.body.token);
|
||||
expect(token?.recording).toMatchObject({ hostId: 7, protocol: "rdp" });
|
||||
expect(token?.connection.settings["recording-name"]).toEqual(
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rest of the routes", () => {
|
||||
it("mints a quick connect token with the caller's defaults", async () => {
|
||||
server = await start();
|
||||
await server.mock.ctx.settings.setUser("user-1", "colorDepth", "24");
|
||||
const response = await server.request("POST", "/token", {
|
||||
type: "rdp",
|
||||
hostname: "10.0.0.9",
|
||||
username: "u",
|
||||
password: "p",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(
|
||||
tokens.decryptToken(response.body.token)?.connection.settings,
|
||||
).toMatchObject({ hostname: "10.0.0.9", port: 3389, "color-depth": 24 });
|
||||
});
|
||||
|
||||
it("offers native RDP only in the desktop app", async () => {
|
||||
server = await start();
|
||||
expect((await server.request("GET", "/native-rdp")).body).toEqual({
|
||||
available: false,
|
||||
});
|
||||
expect(
|
||||
(await server.request("POST", "/native-rdp", { host: "10.0.0.7" }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it("does not hand out another user's session id", async () => {
|
||||
server = await start();
|
||||
const response = await server.request("GET", "/connection/unknown");
|
||||
expect(response.body).toEqual({ guacamoleConnectionId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("activate", () => {
|
||||
it("fails closed without network:serve", async () => {
|
||||
await expect(
|
||||
start({
|
||||
capabilities: manifest.capabilities.filter(
|
||||
(capability) => capability !== "network:serve",
|
||||
),
|
||||
}),
|
||||
).rejects.toThrow(/network:serve/);
|
||||
});
|
||||
|
||||
it("cannot read a host's login without credentials:read", async () => {
|
||||
server = await start({
|
||||
protocolTargets: { "7:rdp": target() },
|
||||
capabilities: manifest.capabilities.filter(
|
||||
(capability) => capability !== "credentials:read",
|
||||
),
|
||||
});
|
||||
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
|
||||
const response = await server.request("POST", "/connect-host/7", {
|
||||
protocol: "rdp",
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
expect(server.mock.checked).toContain("credentials:read");
|
||||
});
|
||||
|
||||
it("provides sessions.live for rdp, vnc and telnet", async () => {
|
||||
server = await start();
|
||||
expect([...server.mock.services.keys()].sort()).toEqual(
|
||||
expect.arrayContaining([
|
||||
"sessions.live#rdp",
|
||||
"sessions.live#telnet",
|
||||
"sessions.live#vnc",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RemoteSessions } from "../../src/backend/sessions.js";
|
||||
import { createLiveSessions } from "../../src/backend/live-sessions.js";
|
||||
import { GuacamoleTokenService } from "../../src/backend/token-service.js";
|
||||
|
||||
const meta = {
|
||||
termixConnectId: "c1",
|
||||
hostId: 7,
|
||||
hostName: "Win box",
|
||||
ownerUserId: "user-1",
|
||||
protocol: "rdp" as const,
|
||||
tabInstanceId: "tab-1",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("RemoteSessions", () => {
|
||||
it("moves parked resources onto the session and releases them on close", () => {
|
||||
const sessions = new RemoteSessions();
|
||||
const tunnel = vi.fn();
|
||||
const tracking = vi.fn();
|
||||
sessions.park("c1", [tunnel]);
|
||||
sessions.opened(meta, "g1", [tracking]);
|
||||
|
||||
expect(sessions.byConnect("c1")?.guacamoleConnectionId).toBe("g1");
|
||||
expect(sessions.byGuacamole("g1")?.hostName).toBe("Win box");
|
||||
expect(tunnel).not.toHaveBeenCalled();
|
||||
|
||||
sessions.closed("c1");
|
||||
expect(tunnel).toHaveBeenCalledOnce();
|
||||
expect(tracking).toHaveBeenCalledOnce();
|
||||
expect(sessions.byGuacamole("g1")).toBeNull();
|
||||
});
|
||||
|
||||
it("releases a tunnel guacd never used", () => {
|
||||
vi.useFakeTimers();
|
||||
const sessions = new RemoteSessions(1000);
|
||||
const tunnel = vi.fn();
|
||||
sessions.park("c1", [tunnel]);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(tunnel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("releases everything on clear, and one failing close does not stop the rest", () => {
|
||||
const sessions = new RemoteSessions();
|
||||
const failing = vi.fn(() => {
|
||||
throw new Error("already closed");
|
||||
});
|
||||
const pending = vi.fn();
|
||||
const open = vi.fn();
|
||||
sessions.park("c2", [failing, pending]);
|
||||
sessions.park("c1", [open]);
|
||||
sessions.opened(meta, "g1");
|
||||
sessions.clear();
|
||||
expect(pending).toHaveBeenCalledOnce();
|
||||
expect(open).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessions.live providers", () => {
|
||||
const tokens = new GuacamoleTokenService();
|
||||
|
||||
it("describes a live session of its own protocol only", () => {
|
||||
const sessions = new RemoteSessions();
|
||||
sessions.opened(meta, "g1");
|
||||
const deps = { sessions, tokens, endSession: vi.fn(() => true) };
|
||||
const rdp = createLiveSessions("rdp", deps);
|
||||
const vnc = createLiveSessions("vnc", deps);
|
||||
|
||||
expect(rdp.getSession("g1")).toMatchObject({
|
||||
id: "g1",
|
||||
userId: "user-1",
|
||||
hostId: 7,
|
||||
hostName: "Win box",
|
||||
isConnected: true,
|
||||
tabInstanceId: "tab-1",
|
||||
});
|
||||
expect(vnc.getSession("g1")).toBeNull();
|
||||
});
|
||||
|
||||
it("mints a join token for a viewer and refuses an unknown session", () => {
|
||||
const sessions = new RemoteSessions();
|
||||
sessions.opened(meta, "g1");
|
||||
const rdp = createLiveSessions("rdp", {
|
||||
sessions,
|
||||
tokens,
|
||||
endSession: vi.fn(() => true),
|
||||
});
|
||||
|
||||
const token = tokens.decryptToken(rdp.createViewerToken("g1", true));
|
||||
expect(token?.connection).toMatchObject({ join: "g1", readOnly: true });
|
||||
expect(token?.recording).toBeUndefined();
|
||||
expect(() => rdp.createViewerToken("nope", false)).toThrow();
|
||||
});
|
||||
|
||||
it("ends the session for its owner", () => {
|
||||
const sessions = new RemoteSessions();
|
||||
sessions.opened(meta, "g1");
|
||||
const endSession = vi.fn(() => true);
|
||||
const rdp = createLiveSessions("rdp", { sessions, tokens, endSession });
|
||||
rdp.ownerEndSession("g1", "owner ended");
|
||||
expect(endSession).toHaveBeenCalledWith("g1");
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import ts from "typescript";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
it("returns the minted token without waiting for a connection that needs that token", async () => {
|
||||
const source = readFileSync(
|
||||
new URL("../../src/backend/routes.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const ast = ts.createSourceFile(
|
||||
"routes.ts",
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const route = ast.statements.find(
|
||||
(s) =>
|
||||
ts.isExpressionStatement(s) &&
|
||||
ts.isCallExpression(s.expression) &&
|
||||
s.expression.arguments[0]?.getText(ast) === '"/connect-host/:hostId"',
|
||||
) as ts.ExpressionStatement;
|
||||
const callback = (route.expression as ts.CallExpression)
|
||||
.arguments[1] as ts.ArrowFunction;
|
||||
const block = (callback.body as ts.Block).statements[0] as ts.TryStatement;
|
||||
const index = block.tryBlock.statements
|
||||
.map((s, i) => (ts.isSwitchStatement(s) ? i : -1))
|
||||
.filter((i) => i >= 0)
|
||||
.at(-1)!;
|
||||
expect(index).toBeGreaterThan(-1);
|
||||
const tail = block.tryBlock.statements
|
||||
.slice(index + 1)
|
||||
.map((s) => s.getText(ast))
|
||||
.join("\n");
|
||||
const json = vi.fn();
|
||||
const wait = vi.fn(() => {
|
||||
throw new Error("Client has not received the token yet");
|
||||
});
|
||||
await runInNewContext(
|
||||
ts.transpileModule(`(async () => { ${tail} })()`, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText,
|
||||
{
|
||||
waitForGuacdOpen: wait,
|
||||
token: "minted-token",
|
||||
termixConnectId: "correlation-id",
|
||||
req: {},
|
||||
getRequestMeta: () => ({}),
|
||||
logAudit: async () => {},
|
||||
getAuditUsername: async () => "owner",
|
||||
userId: "owner",
|
||||
connectionType: "rdp",
|
||||
hostId: 7,
|
||||
hostname: "host",
|
||||
port: 3389,
|
||||
res: { json },
|
||||
},
|
||||
);
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
token: "minted-token",
|
||||
termixConnectId: "correlation-id",
|
||||
guacamoleConnectionId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("only reveals the live session ID to its owner", () => {
|
||||
const source = readFileSync(
|
||||
new URL("../../src/backend/guacamole-server.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const ast = ts.createSourceFile(
|
||||
"server.ts",
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const fn = ast.statements.find(
|
||||
(s) =>
|
||||
ts.isFunctionDeclaration(s) &&
|
||||
s.name?.text === "getGuacSessionByConnectId",
|
||||
)!;
|
||||
const info = { ownerUserId: "owner", guacamoleConnectionId: "guacd-id" };
|
||||
const context = {
|
||||
exports: {} as Record<string, (id: string, user: string) => unknown>,
|
||||
guacSessionByConnectId: new Map([["connect-id", info]]),
|
||||
};
|
||||
runInNewContext(
|
||||
ts.transpileModule(fn.getText(ast), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText,
|
||||
context,
|
||||
);
|
||||
const lookup = context.exports.getGuacSessionByConnectId;
|
||||
expect(lookup("connect-id", "owner")).toEqual(info);
|
||||
expect(lookup("connect-id", "other")).toBeNull();
|
||||
expect(lookup("pending", "owner")).toBeNull();
|
||||
});
|
||||
@@ -1,20 +1,9 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
vi.mock("../../../../src/backend/utils/logger.js", () => ({
|
||||
guacLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { GuacamoleTokenService } =
|
||||
await import("../../src/backend/token-service.js");
|
||||
import { GuacamoleTokenService } from "../../src/backend/token-service.js";
|
||||
|
||||
describe("GuacamoleTokenService", () => {
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
const tokenService = new GuacamoleTokenService();
|
||||
|
||||
it("disables RDP pre-authentication when no credentials are configured", () => {
|
||||
const token = tokenService.createRdpToken("windows.example.test", "", "");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
@@ -45,9 +45,39 @@ describe(`${manifest.id} activate`, () => {
|
||||
expect(app.registered.panels()).toEqual([]);
|
||||
expect(app.registered.railItems()).toEqual([]);
|
||||
expect(app.registered.hostActions()).toEqual([]);
|
||||
expect(app.registered.hostProtocols()).toEqual([]);
|
||||
expect(app.registered.hostEditorSections()).toEqual([]);
|
||||
expect(app.registered.dashboardCards()).toEqual([]);
|
||||
expect(app.registered.settingsComponents()).toEqual([]);
|
||||
expect(app.registered.actions()).toEqual([]);
|
||||
});
|
||||
|
||||
it("adds RDP, VNC and Telnet as host protocols while turned on", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: { get: async () => ({ data: { enabled: true } }) } as never,
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(rendered!.registered.hostProtocols().sort()).toEqual([
|
||||
"rdp",
|
||||
"telnet",
|
||||
"vnc",
|
||||
]),
|
||||
);
|
||||
expect(rendered.registered.hostActions().map((a) => a.id)).toEqual(
|
||||
expect.arrayContaining(["rdp", "vnc", "telnet"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("offers no way in while an admin has turned it off", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: { get: async () => ({ data: { enabled: false } }) } as never,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(rendered.registered.hostProtocols()).toEqual([]);
|
||||
expect(rendered.registered.hostActions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -11,22 +11,27 @@ const remoteApiMock = vi.hoisted(() => ({
|
||||
const isElectronMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const resolveRemoteHostIdMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
authApi: authApiMock,
|
||||
getRemoteGuacamoleApi: () => remoteApiMock,
|
||||
vi.mock("@termix/plugin-sdk/ui", () => ({
|
||||
isElectron: isElectronMock,
|
||||
handleApiError: (error: unknown) => error,
|
||||
}));
|
||||
vi.mock("@/lib/remote-server-api", () => ({
|
||||
resolveRemoteHostId: resolveRemoteHostIdMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
setRemoteDesktopApp,
|
||||
getGuacdStatus,
|
||||
getGuacamoleConnectionId,
|
||||
getGuacamoleTokenFromHost,
|
||||
} from "../../src/frontend/guacamole-api";
|
||||
|
||||
// Mirrors app.apiFor: the remote server only for a remote origin in the
|
||||
// desktop app, the ordinary client otherwise.
|
||||
setRemoteDesktopApp({
|
||||
apiFor: (origin) =>
|
||||
(origin === "remote" && isElectronMock()
|
||||
? remoteApiMock
|
||||
: authApiMock) as never,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
authApiMock.get.mockClear();
|
||||
authApiMock.post.mockClear();
|
||||
@@ -42,9 +47,7 @@ describe("guacamole API origin", () => {
|
||||
await getGuacdStatus("remote");
|
||||
await getGuacamoleTokenFromHost(9, "remote", "vnc");
|
||||
|
||||
expect(authApiMock.get).toHaveBeenCalledWith(
|
||||
"/plugin-api/remote-desktop/status",
|
||||
);
|
||||
expect(authApiMock.get).toHaveBeenCalledWith("/status");
|
||||
expect(authApiMock.post).toHaveBeenCalledOnce();
|
||||
expect(remoteApiMock.get).not.toHaveBeenCalled();
|
||||
expect(remoteApiMock.post).not.toHaveBeenCalled();
|
||||
@@ -60,9 +63,7 @@ describe("guacamole API origin", () => {
|
||||
|
||||
expect(status.guacd.status).toBe("connected");
|
||||
expect(token.token).toBe("remote-token");
|
||||
expect(remoteApiMock.get).toHaveBeenCalledWith(
|
||||
"/plugin-api/remote-desktop/status",
|
||||
);
|
||||
expect(remoteApiMock.get).toHaveBeenCalledWith("/status");
|
||||
expect(remoteApiMock.post).toHaveBeenCalledOnce();
|
||||
expect(authApiMock.get).not.toHaveBeenCalled();
|
||||
expect(authApiMock.post).not.toHaveBeenCalled();
|
||||
@@ -84,15 +85,12 @@ describe("guacamole API origin", () => {
|
||||
"host-sync-id",
|
||||
);
|
||||
|
||||
expect(remoteApiMock.post).toHaveBeenCalledWith(
|
||||
"/plugin-api/remote-desktop/connect-host/41",
|
||||
{
|
||||
protocol: "rdp",
|
||||
promptedUsername: "admin",
|
||||
promptedPassword: "secret",
|
||||
promptedDomain: "EXAMPLE",
|
||||
},
|
||||
);
|
||||
expect(remoteApiMock.post).toHaveBeenCalledWith("/connect-host/41", {
|
||||
protocol: "rdp",
|
||||
promptedUsername: "admin",
|
||||
promptedPassword: "secret",
|
||||
promptedDomain: "EXAMPLE",
|
||||
});
|
||||
expect(resolveRemoteHostIdMock).toHaveBeenCalledWith("host-sync-id");
|
||||
});
|
||||
|
||||
@@ -105,15 +103,12 @@ describe("guacamole API origin", () => {
|
||||
domain: "",
|
||||
});
|
||||
|
||||
expect(authApiMock.post).toHaveBeenCalledWith(
|
||||
"/plugin-api/remote-desktop/connect-host/9",
|
||||
{
|
||||
protocol: "rdp",
|
||||
promptedUsername: "local-admin",
|
||||
promptedPassword: "secret",
|
||||
promptedDomain: "",
|
||||
},
|
||||
);
|
||||
expect(authApiMock.post).toHaveBeenCalledWith("/connect-host/9", {
|
||||
protocol: "rdp",
|
||||
promptedUsername: "local-admin",
|
||||
promptedPassword: "secret",
|
||||
promptedDomain: "",
|
||||
});
|
||||
});
|
||||
|
||||
// Support#1240: a host set to "This Device" is served by the embedded
|
||||
@@ -125,12 +120,9 @@ describe("guacamole API origin", () => {
|
||||
const token = await getGuacamoleTokenFromHost(9, "local", "rdp");
|
||||
|
||||
expect(token.token).toBe("local-token");
|
||||
expect(authApiMock.post).toHaveBeenCalledWith(
|
||||
"/plugin-api/remote-desktop/connect-host/9",
|
||||
{
|
||||
protocol: "rdp",
|
||||
},
|
||||
);
|
||||
expect(authApiMock.post).toHaveBeenCalledWith("/connect-host/9", {
|
||||
protocol: "rdp",
|
||||
});
|
||||
expect(remoteApiMock.post).not.toHaveBeenCalled();
|
||||
expect(resolveRemoteHostIdMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -141,9 +133,7 @@ describe("guacamole API origin", () => {
|
||||
const status = await getGuacdStatus("local");
|
||||
|
||||
expect(status.guacd.status).toBe("disconnected");
|
||||
expect(authApiMock.get).toHaveBeenCalledWith(
|
||||
"/plugin-api/remote-desktop/status",
|
||||
);
|
||||
expect(authApiMock.get).toHaveBeenCalledWith("/status");
|
||||
expect(remoteApiMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -177,9 +167,8 @@ it("looks up the session on the backend that issued its token", async () => {
|
||||
isElectronMock.mockReturnValue(true);
|
||||
const signal = new AbortController().signal;
|
||||
await getGuacamoleConnectionId("connect/id", "remote", signal);
|
||||
expect(remoteApiMock.get).toHaveBeenCalledWith(
|
||||
"/plugin-api/remote-desktop/connection/connect%2Fid",
|
||||
{ signal },
|
||||
);
|
||||
expect(remoteApiMock.get).toHaveBeenCalledWith("/connection/connect%2Fid", {
|
||||
signal,
|
||||
});
|
||||
expect(authApiMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { buildRdpFile, validateNativeRdpOptions } =
|
||||
require("../../../../electron/native-rdp.cjs") as {
|
||||
buildRdpFile: (options: Record<string, unknown>) => string;
|
||||
validateNativeRdpOptions: (options: Record<string, unknown>) => unknown;
|
||||
};
|
||||
|
||||
describe("native Windows RDP", () => {
|
||||
it("creates a credential-prompting RDP file without a password", () => {
|
||||
const result = buildRdpFile({
|
||||
host: "server.example.com",
|
||||
port: 3390,
|
||||
username: "alice",
|
||||
domain: "EXAMPLE",
|
||||
password: "must-not-be-written",
|
||||
});
|
||||
|
||||
expect(result).toContain("full address:s:server.example.com:3390");
|
||||
expect(result).toContain("username:s:EXAMPLE\\alice");
|
||||
expect(result).toContain("prompt for credentials:i:1");
|
||||
expect(result).not.toContain("must-not-be-written");
|
||||
});
|
||||
|
||||
it("rejects unsafe host and port values", () => {
|
||||
expect(() =>
|
||||
validateNativeRdpOptions({ host: "bad\r\nvalue" }),
|
||||
).not.toThrow();
|
||||
expect(() => validateNativeRdpOptions({ host: "server/path" })).toThrow();
|
||||
expect(() =>
|
||||
validateNativeRdpOptions({ host: "server", port: 70000 }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,41 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createQuickConnectHost } from "@/sidebar/quick-connect-host";
|
||||
import { quickConnectGuacHost } from "../../src/frontend/quick-connect-guac-host";
|
||||
|
||||
// The shape core's Quick Connect builds for a plugin protocol: the switch and
|
||||
// port in this plugin's host settings, the login on the protocol's fields.
|
||||
function quickHost(protocol: "rdp" | "vnc", extra: Record<string, unknown>) {
|
||||
return {
|
||||
id: "quick-connect-1",
|
||||
name: "admin@10.0.0.2",
|
||||
ip: "10.0.0.2",
|
||||
pluginSettings: {
|
||||
"remote-desktop": {
|
||||
[protocol === "rdp" ? "enableRdp" : "enableVnc"]: true,
|
||||
[protocol === "rdp" ? "rdpPort" : "vncPort"]:
|
||||
protocol === "rdp" ? 3390 : 5901,
|
||||
},
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe("quickConnectGuacHost", () => {
|
||||
it("carries an RDP quick connect's address and credentials", () => {
|
||||
const host = createQuickConnectHost({
|
||||
ip: "10.0.0.2",
|
||||
port: 3390,
|
||||
username: "admin",
|
||||
authType: "password",
|
||||
password: "pw",
|
||||
protocol: "rdp",
|
||||
domain: "CORP",
|
||||
});
|
||||
expect(quickConnectGuacHost(host)).toMatchObject({
|
||||
expect(
|
||||
quickConnectGuacHost(
|
||||
quickHost("rdp", {
|
||||
rdpUser: "admin",
|
||||
rdpPassword: "pw",
|
||||
domain: "CORP",
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
ip: "10.0.0.2",
|
||||
connectionType: "rdp",
|
||||
rdpPort: 3390,
|
||||
rdpUser: "admin",
|
||||
rdpPassword: "pw",
|
||||
port: 3390,
|
||||
username: "admin",
|
||||
password: "pw",
|
||||
domain: "CORP",
|
||||
});
|
||||
});
|
||||
|
||||
it("puts a VNC quick connect's password on the VNC fields", () => {
|
||||
const host = createQuickConnectHost({
|
||||
ip: "10.0.0.3",
|
||||
port: 5901,
|
||||
username: "",
|
||||
authType: "password",
|
||||
password: "vncpw",
|
||||
protocol: "vnc",
|
||||
});
|
||||
expect(quickConnectGuacHost(host)).toMatchObject({
|
||||
it("takes a VNC quick connect's login from the VNC fields", () => {
|
||||
expect(
|
||||
quickConnectGuacHost(quickHost("vnc", { vncPassword: "vncpw" })),
|
||||
).toMatchObject({
|
||||
connectionType: "vnc",
|
||||
vncPort: 5901,
|
||||
vncPassword: "vncpw",
|
||||
port: 5901,
|
||||
password: "vncpw",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/types/*": ["../../src/types/*"],
|
||||
"@/*": ["../../src/ui/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
Reference in new issue
Block a user