feat: finish the remote-desktop plugin (B14)

This commit is contained in:
LukeGus committed 2026-09-24 01:02:45 -05:00
1 parent 0af2c94983
commit 14eb558b85
215 files changed
+30029 -3921

No files matched your search

+1 -1
View File
@@ -161,7 +161,7 @@ http {
root /app/html; root /app/html;
index index.html index.htm; index index.html index.htm;
expires off; expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-IaCkn7qYpeosdQvIY2TXZn11ghTwUsfkSNLXPkjBcSY='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-don5VKNqA8VQplPO1j5zyOVDdbTQrgGSSeVPwJ7b0Lo='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always; add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+1 -1
View File
@@ -142,7 +142,7 @@ http {
root /app/html; root /app/html;
index index.html index.htm; index index.html index.htm;
expires off; expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-IaCkn7qYpeosdQvIY2TXZn11ghTwUsfkSNLXPkjBcSY='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-don5VKNqA8VQplPO1j5zyOVDdbTQrgGSSeVPwJ7b0Lo='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always; add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
@@ -0,0 +1,7 @@
-- The remote desktop host options (enable_rdp, enable_vnc, enable_telnet,
-- the three ports, rdp_security, rdp_ignore_cert, security, ignore_cert,
-- guacamole_config) and user_preferences.rdp_defaults moved to the
-- remote-desktop plugin's settings. The copy runs after plugins load, which
-- is after this migration, so a real DROP COLUMN here would delete the data
-- first. The columns stay in place, unused, until 3.0.0.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -295,6 +295,13 @@
"when": 1790225600497, "when": 1790225600497,
"tag": "0041_wonderful_cargill", "tag": "0041_wonderful_cargill",
"breakpoints": true "breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1790227893950,
"tag": "0042_colorful_bill_hollister",
"breakpoints": true
} }
] ]
} }
+7
View File
@@ -0,0 +1,7 @@
-- The remote desktop host options (enable_rdp, enable_vnc, enable_telnet,
-- the three ports, rdp_security, rdp_ignore_cert, security, ignore_cert,
-- guacamole_config) and user_preferences.rdp_defaults moved to the
-- remote-desktop plugin's settings. The copy runs after plugins load, which
-- is after this migration, so a real DROP COLUMN here would delete the data
-- first. The columns stay in place, unused, until 3.0.0.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -302,6 +302,13 @@
"when": 1790225599928, "when": 1790225599928,
"tag": "0042_strange_shocker", "tag": "0042_strange_shocker",
"breakpoints": true "breakpoints": true
},
{
"idx": 43,
"version": "7",
"when": 1790227893380,
"tag": "0043_melodic_terror",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,7 @@
-- The remote desktop host options (enable_rdp, enable_vnc, enable_telnet,
-- the three ports, rdp_security, rdp_ignore_cert, security, ignore_cert,
-- guacamole_config) and user_preferences.rdp_defaults moved to the
-- remote-desktop plugin's settings. The copy runs after plugins load, which
-- is after this migration, so a real DROP COLUMN here would delete the data
-- first. The columns stay in place, unused, until 3.0.0.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -274,6 +274,13 @@
"when": 1790225599344, "when": 1790225599344,
"tag": "0038_broad_avengers", "tag": "0038_broad_avengers",
"breakpoints": true "breakpoints": true
},
{
"idx": 39,
"version": "6",
"when": 1790227892787,
"tag": "0039_lively_impossible_man",
"breakpoints": true
} }
] ]
} }
+1 -4
View File
@@ -645,6 +645,7 @@ const isolatedWindows = createIsolatedWindows({
// ctx.desktop.openIsolatedWindow is the one caller today. // ctx.desktop.openIsolatedWindow is the one caller today.
const BACKEND_REQUEST_HANDLERS = { const BACKEND_REQUEST_HANDLERS = {
"open-isolated-window": (payload) => isolatedWindows.open(payload), "open-isolated-window": (payload) => isolatedWindows.open(payload),
"launch-native-rdp": (payload) => launchNativeRdp(payload),
}; };
async function handleBackendRequest(msg) { async function handleBackendRequest(msg) {
@@ -1672,10 +1673,6 @@ ipcMain.handle("get-platform", () => {
return process.platform; return process.platform;
}); });
ipcMain.handle("open-native-rdp", (_event, options) =>
launchNativeRdp(options),
);
ipcMain.handle("get-embedded-server-status", () => { ipcMain.handle("get-embedded-server-status", () => {
return { return {
running: running:
-1
View File
@@ -30,7 +30,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
getPlatform: () => ipcRenderer.invoke("get-platform"), getPlatform: () => ipcRenderer.invoke("get-platform"),
getEmbeddedServerStatus: () => getEmbeddedServerStatus: () =>
ipcRenderer.invoke("get-embedded-server-status"), ipcRenderer.invoke("get-embedded-server-status"),
openNativeRdp: (options) => ipcRenderer.invoke("open-native-rdp", options),
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel), removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
isElectron: true, isElectron: true,
+97 -18
View File
@@ -279,6 +279,12 @@ it, `group` is a section heading, and `permission` is who may write it: admin
fields fall back to `admin.plugins.manage`, and a short name resolves against fields fall back to `admin.plugins.manage`, and a short name resolves against
the plugin's own permissions exactly as `ctx.rbac` does. the plugin's own permissions exactly as `ctx.rbac` does.
`hidden: true` (**B14**) keeps a field out of the generic form: it is stored,
validated and encrypted like any other, but the plugin edits it in its own
UI. Remote desktop's host options are hidden fields, edited in its RDP, VNC
and Telnet host editor tabs, so they do not appear a second time in the
Plugins tab.
`type: "custom"` names a component the frontend registered, for the few things `type: "custom"` names a component the frontend registered, for the few things
a schema cannot express (a device browser, a provider list). It is deliberately a schema cannot express (a device browser, a provider list). It is deliberately
narrow: a plugin supplies data everywhere else, so it cannot ship its own form narrow: a plugin supplies data everywhere else, so it cannot ship its own form
@@ -339,8 +345,10 @@ A plugin frontend is a built ESM bundle, `dist/frontend.js`, exporting
no imports from `plugins/`; everything a plugin shows goes through the `app` no imports from `plugins/`; everything a plugin shows goes through the `app`
object. `scripts/check-shell-plugin-ids.cjs` (run by lint) fails on a quoted object. `scripts/check-shell-plugin-ids.cjs` (run by lint) fails on a quoted
plugin id in `src/ui` outside tests, apart from the entries in plugin id in `src/ui` outside tests, apart from the entries in
`scripts/shell-plugin-id-allowlist.json`, each with a reason (host protocol `scripts/shell-plugin-id-allowlist.json`, each with a reason (host data like
data like `rdp`, which Phase B moves). the `docker` runtime name, which Phase B moves). **B14** removed the `rdp`,
`vnc` and `telnet` entries: the shell learns those protocols from
`app.registerHostProtocol`.
#### The loader #### The loader
@@ -427,6 +435,7 @@ boundary and Suspense.
| `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs | | `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs |
| `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` | | `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` |
| `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect | | `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect |
| `registerHostProtocol` | A connection protocol next to SSH (**B14**): General tab switch, Hosts panel filter and grouping, Quick Connect, the host's primary type and port |
| `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries | | `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries |
| `registerPaletteEntry` | A global or per-host command palette entry | | `registerPaletteEntry` | A global or per-host command palette entry |
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` | | `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
@@ -437,6 +446,7 @@ boundary and Suspense.
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor | | `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method and a second factor challenge (plus an optional `enrollment` section) | | `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method and a second factor challenge (plus an optional `enrollment` section) |
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL | | `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
| `apiFor(origin)` | The same client for a resolved connection origin: `"remote"` reaches the desktop app's connected server (**B14**) |
| `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) | | `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) |
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view | | `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view |
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup | | `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
@@ -504,6 +514,19 @@ their host info), `terminal.sendToActive(text, { run })` and
its terminal tab wrapper populates from the core terminal ref it already its terminal tab wrapper populates from the core terminal ref it already
holds. The snippets plugin's run/paste flow is the first caller. holds. The snippets plugin's run/paste flow is the first caller.
**Host protocols (B14).** `app.registerHostProtocol({ id, settingKey,
portKey, defaultPort, titleKey, descriptionKey, icon, quickConnect })` adds a
protocol next to SSH. Its switch and port are the plugin's own host settings,
so core reads them from `host.pluginSettings[<plugin>]` and never knows the
protocol by name: the General tab draws the switch and saves it into the
plugin's settings, the Hosts panel filters and groups by it, HostItem counts
it, Quick Connect offers it (with a domain field when asked), and a host with
SSH off takes the first protocol switched on as its `connectionType` and its
port. `hostProtocols(host)` from `@termix/plugin-sdk/frontend` lists what a
host has on (`"ssh"` included), for a plugin such as session sharing that
offers every protocol without knowing who owns it. Remote desktop registers
`rdp`, `vnc` and `telnet`.
#### View ownership #### View ownership
Who owns a tab, panel or card comes from manifests, so it is known even for a Who owns a tab, panel or card comes from manifests, so it is known even for a
@@ -547,7 +570,11 @@ connection helpers (`isElectron`, `resolveConnectionOrigin`, `pluginWsUrl`,
`hydrateLocalSharedHostAuth`, `useConnectionDefaults`); and a few core APIs `hydrateLocalSharedHostAuth`, `useConnectionDefaults`); and a few core APIs
(`logActivity`, `getHostPassword`, `patchOpenTab`, `getUserPreferences`, (`logActivity`, `getHostPassword`, `patchOpenTab`, `getUserPreferences`,
`parseCustomKeybindings`, `setHostAutoTmux`, `getCookie`). The APIs are a `parseCustomKeybindings`, `setHostAutoTmux`, `getCookie`). The APIs are a
stopgap in the right place: D1 turns them into typed bridge members. Publishing its stopgap in the right place: D1 turns them into typed bridge members.
**B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the
`ConnectionStage` type, for remote desktop's display socket and its remote
host lookup. `useConnectionDefaults` now carries terminal defaults only; the
RDP half became remote desktop's user settings. Publishing its
`.d.ts` for plugins outside this repo is a follow-up for the repo split. `.d.ts` for plugins outside this repo is a follow-up for the repo split.
#### Strings #### Strings
@@ -596,10 +623,12 @@ every enabled plugin that declares `contributes.settings.host`, it looks up
`"<id>.hostImportNormalizer"` and, if the plugin registered one, calls it with `"<id>.hostImportNormalizer"` and, if the plugin registered one, calls it with
the raw imported row and writes back whatever it returns (or nothing, for the raw imported row and writes back whatever it returns (or nothing, for
`null`). This is not part of the SDK's typed `ctx` surface - it is a plain `null`). This is not part of the SDK's typed `ctx` surface - it is a plain
`ctx.registry` convention, the same mechanism `remote-desktop.sessions` `ctx.registry` convention, just with a name core's own code knows to look
already uses, just with a name core's own code knows for. web-endpoint (`src/backend/host-import.ts`) is the first plugin to
to look for. web-endpoint (`src/backend/host-import.ts`) is the first plugin register one; remote desktop's (**B14**) also reads a Termix export's
to register one. `pluginSettings` and the flat fields hosts had before 2.9.0. Host exports
carry every plugin's host settings (secrets redacted) under `pluginSettings`,
which is what makes the round trip work without core naming a plugin.
**B9**'s terminal provides `sessions.live` (live SSH sessions: look up, end, **B9**'s terminal provides `sessions.live` (live SSH sessions: look up, end,
remove a share's participants, hand a room share's control over, subscribe to remove a share's participants, hand a room share's control over, subscribe to
@@ -623,8 +652,11 @@ per-chunk itself), `persist(summary)` and `discard()` for a session that
recorded nothing. `createFinished(input)` is the second half of the recorded nothing. `createFinished(input)` is the second half of the
contract, for a caller that already wrote its own recording to disk and only contract, for a caller that already wrote its own recording to disk and only
needs the database row: remote-desktop's guacd recordings use it instead of needs the database row: remote-desktop's guacd recordings use it instead of
importing the old repository directly, and without the plugin enabled a importing the old repository directly. **B14** added `enabledFor(hostId)`
guacd recording still lands on disk but gets no row. Recording files live (version 1.1.0), because guacd has to be told to record before the session
exists: remote desktop records an RDP or Telnet session only when the plugin
is running and the host's switch is on, and writes the row as the session's
user. Recording files live
under `ctx.files.dataDir()/session_logs/<user>/<session>.cast`. Retention under `ctx.files.dataDir()/session_logs/<user>/<session>.cast`. Retention
(`retentionDays`, an admin setting) runs a sweep at boot and every 24 hours. (`retentionDays`, an admin setting) runs a sweep at boot and every 24 hours.
@@ -647,8 +679,10 @@ any other, `ctx.services.get(service, { provider })` reaches one, and
`ctx.services.providers(service)` lists the running ones. A requirement is `ctx.services.providers(service)` lists the running ones. A requirement is
satisfied by any compatible provider. `sessions.live` is the first: keyed by satisfied by any compatible provider. `sessions.live` is the first: keyed by
session type, ssh-terminal provides `ssh` and remote desktop provides `rdp`, session type, ssh-terminal provides `ssh` and remote desktop provides `rdp`,
`vnc` and `telnet` (B14), with `createViewerToken(sessionId, readOnly)` for `vnc` and `telnet` (**B14** delivered them), with
a viewer. A consumer with no actor, such as a public guest route, calls it `createViewerToken(sessionId, readOnly)` for a viewer. A remote desktop
session id is guacd's own connection id, and `ownerEndSession` closes it and
every viewer joined to it. A consumer with no actor, such as a public guest route, calls it
through `ctx.asUser(<share owner>)`. through `ctx.asUser(<share owner>)`.
**Session sharing (B12)** is the session-sharing plugin. It provides **Session sharing (B12)** is the session-sharing plugin. It provides
@@ -954,6 +988,19 @@ dropping a column before its data has moved loses it:
A column that is still read anywhere in core is not ready to be dropped. Ship A column that is still read anywhere in core is not ready to be dropped. Ship
the copy and the switch first, and drop it in a later step if that is safer. the copy and the switch first, and drop it in a later step if that is safer.
**Order at boot (B14).** On Postgres and MySQL, core's drizzle migrations run
while the database opens, and the copies into `plugin_settings` run later,
after the plugins table exists. A real `DROP COLUMN` in step 4 therefore
deletes the data before it is copied. So step 4 removes the column from
`schema.ts`, the pg/mysql variants and the `db/index.ts` bootstrap, and the
generated drizzle migration's `DROP COLUMN` statements are replaced with a
comment and `SELECT 1;`, exactly as for an adopted table. The column stays in
the database, unused, until 3.0.0 removes it. A copy reads it with raw SQL
through `utils/crypto-migration/raw-rows.ts`, whose `selectRows` and
`runStatement` work on all three engines: `getDb().all()` exists only on
SQLite. Remote desktop's host options and `user_preferences.rdp_defaults` are
the first columns handled this way.
--- ---
## Manifest v2 reference ## Manifest v2 reference
@@ -1230,6 +1277,8 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.notify.*` | `notify:send` | A6 | | `ctx.notify.*` | `notify:send` | A6 |
| `ctx.auth.*` | `auth:provide` | **A8** | | `ctx.auth.*` | `auth:provide` | **A8** |
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** | | `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
| `ctx.audit.record` | none, the actor is the runtime's | **B9** | | `ctx.audit.record` | none, the actor is the runtime's | **B9** |
| `ctx.fetch` | `network:outbound` | B | | `ctx.fetch` | `network:outbound` | B |
@@ -1364,6 +1413,29 @@ share). Rejects outside the desktop app (`ELECTRON_EMBEDDED` unset, or no
shares cookies or storage with the main window or with another isolated shares cookies or storage with the main window or with another isolated
window. window.
**B14** added `ctx.desktop.launchNativeRdp({ host, port, username, domain })`
over the same bridge (a second entry in `BACKEND_REQUEST_HANDLERS`,
`launch-native-rdp`), which opens mstsc on Windows with a generated `.rdp`
file that never carries the password, and `ctx.desktop.available()`, which
says whether the server runs embedded in the desktop app. The renderer's own
`openNativeRdp` IPC call is gone; the desktop app's frontend always asks its
embedded backend, which is the one that can reach Electron.
**B14** also added `ctx.credentials.resolveHostProtocol(hostId, protocol)`,
the first member behind `credentials:read`. It returns the host's address,
jump hosts and the plaintext RDP, VNC or Telnet login the acting user may use,
for a plugin that has to hand them to another program (guacd). The logins
stay in core next to the host: an owner gets the stored login or the stored
credential it points at; a shared recipient gets only core's sharing
resolution (the owner's shared snapshot or their own override), never the
owner's raw secret; no connect access, or no host, is `null`. Every call is
audited as `plugin_credentials_read`, allowed or refused.
`ctx.hosts.create` (**B14**) hands the created row to every plugin's
`hostImportNormalizer`, the same as a bulk import row, so a plugin creating a
host can set another plugin's host settings (proxmox marks an imported
Windows guest as RDP) without core naming either.
### The actor ### The actor
Never comes from plugin code. It is held in `AsyncLocalStorage` and set two Never comes from plugin code. It is held in `AsyncLocalStorage` and set two
@@ -1888,8 +1960,8 @@ Then, with the app running:
## Legacy core imports: the debt D1 removes ## Legacy core imports: the debt D1 removes
The bundled plugins predate the SDK, apart from workspaces (A9) and snippets The bundled plugins predate the SDK, apart from workspaces (A9), snippets
(B2), which import nothing from core. The others still reach core by relative (B2) and remote-desktop (B14), which import nothing from core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin, path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle `packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
and rewrites to the compiled output path (`../../../backend/backend/...`, or and rewrites to the compiled output path (`../../../backend/backend/...`, or
@@ -1916,8 +1988,8 @@ What the lint fence enforces today, in `eslint.config.mjs`:
| Core importing a plugin backend | **Error** | 0 | - | | Core importing a plugin backend | **Error** | 0 | - |
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - | | A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
| The shell importing plugin code | **Error** | 0 | - | | The shell importing plugin code | **Error** | 0 | - |
| A plugin frontend importing core through `@/` | Warning | 130 files | D1 | | A plugin frontend importing core through `@/` | Warning | 120 files | D1 |
| A plugin importing core by relative path | Warning | 66 files | D1 | | A plugin importing core by relative path | Warning | 63 files | D1 |
| A plugin importing another plugin's source | Warning | 3 files | B18 | | A plugin importing another plugin's source | Warning | 3 files | B18 |
A warning does not fail a build, so the counts are held by A warning does not fail a build, so the counts are held by
@@ -1957,6 +2029,13 @@ Known specifics:
command history panel into ssh-terminal, which imports nothing from core. command history panel into ssh-terminal, which imports nothing from core.
The file manager's terminal window renders the `terminal.view` component The file manager's terminal window renders the `terminal.view` component
instead of importing the core terminal. instead of importing the core terminal.
- Host data columns (`enableDocker`, `enableRdp`, `guacamoleConfig` and so on) - Host data columns (`enableDocker` and so on) stay in core types until
stay in core types until Phase B moves them. Only UI branches on them left Phase B moves them. Only UI branches on them left the shell. **B14** moved
the shell. remote desktop's (`enableRdp`, `enableVnc`, `enableTelnet`, the three ports,
`rdpSecurity`, `rdpIgnoreCert`, `guacamoleConfig`) into its host settings;
the protocol logins stay core host fields.
- **B14** moved remote desktop onto the SDK: it imports nothing from core,
owns no module state, and its guacamole-lite server is built in `activate`
with its own signal handlers removed (guacamole-lite installs SIGTERM and
SIGINT handlers in its constructor). guacd is always an external service,
so there is no `ctx.process`; its address is an admin setting.
+67 -27
View File
@@ -161,11 +161,9 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
ssh-terminal host settings shipped (`ssh-terminal-settings-migration.ts`) ssh-terminal host settings shipped (`ssh-terminal-settings-migration.ts`)
and the terminal reads its settings, but core host routes, types, the and the terminal reads its settings, but core host routes, types, the
export/import sample and `HostEditorData` still carry the columns. export/import sample and `HostEditorData` still carry the columns.
`enable_terminal_toolbar` also drives the RDP/VNC/Telnet toolbar **B14** gave remote desktop its own `enableToolbar` host setting, copied
(`remote-desktop/GuacamoleApp.tsx`), whose host editor switch stays in the from `enable_terminal_toolbar`, so nothing else holds the column up. Drop
General tab, so remote-desktop needs its own host setting and copy first. all three the B14 way (see "Order at boot" in ARCHITECTURE.md). Owner: D0.
Then drop all three in lockstep. Owner: remote-desktop's step for its copy,
then D0.
- **B9 (ssh-terminal):** the legacy `settings` rows for the terminal - **B9 (ssh-terminal):** the legacy `settings` rows for the terminal
(`terminal_session_timeout_minutes`, `terminal_session_persistence_enabled`, (`terminal_session_timeout_minutes`, `terminal_session_persistence_enabled`,
`command_history_enabled`, `touch_input_settings`, `terminal_image_*`) are `command_history_enabled`, `touch_input_settings`, `terminal_image_*`) are
@@ -255,17 +253,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
section. Owner: whoever designs the 3.0.0 install flow (prebuild-per-platform section. Owner: whoever designs the 3.0.0 install flow (prebuild-per-platform
packaging, or a documented "avoid native code" rule for community plugins). packaging, or a documented "avoid native code" rule for community plugins).
- **B12 (session-sharing):** the remote desktop side of sharing has no
provider yet. Session sharing reaches rdp, vnc and telnet sessions through
`sessions.live` named providers `rdp`, `vnc` and `telnet`, each with
`getSession(sessionId)` (id, userId as the session's owner, hostId,
hostName, isConnected, createdAt, tabInstanceId) and
`createViewerToken(sessionId, readOnly)`, typed in
`plugins/session-sharing/src/backend/live.ts`. Until remote-desktop provides
them, sharing or presenting an RDP/VNC/Telnet session is refused and those
links resolve 404. B14 also drops `createJoinToken`/`getSessionInfo` from
its `remote-desktop.sessions` registry entry, which core no longer reads.
Owner: B14.
- **B12 (session-sharing):** `allow_session_sharing` is still a live `ssh_data` - **B12 (session-sharing):** `allow_session_sharing` is still a live `ssh_data`
column. The copy into the plugin's host setting shipped column. The copy into the plugin's host setting shipped
(`session-sharing-settings-migration.ts`) and nothing in core reads or (`session-sharing-settings-migration.ts`) and nothing in core reads or
@@ -291,9 +278,8 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
a real behaviour change from the old column default, made once here rather a real behaviour change from the old column default, made once here rather
than silently. `ssh-terminal/src/backend/terminal-socket.ts` still reads than silently. `ssh-terminal/src/backend/terminal-socket.ts` still reads
`resolvedHostData?.enableSessionLogging ?? hostConfig.enableSessionLogging` `resolvedHostData?.enableSessionLogging ?? hostConfig.enableSessionLogging`
off the resolved SSH host instead of `ctx.settings.getHost`, and off the resolved SSH host instead of `ctx.settings.getHost` (remote desktop
`plugins/remote-desktop/src/backend/routes.ts`'s asks `recordings.writer.enabledFor` since B14), and `HostEditorData.ts`,
`host.enableSessionLogging !== false` check, `HostEditorData.ts`,
`HostManagerData.ts`, `quick-connect-host.ts` and the export/import sample `HostManagerData.ts`, `quick-connect-host.ts` and the export/import sample
all still read the column directly. The General tab's checkbox was removed all still read the column directly. The General tab's checkbox was removed
from `HostEditor.tsx` (the schema-driven Plugins group replaces it); the from `HostEditor.tsx` (the schema-driven Plugins group replaces it); the
@@ -301,14 +287,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
(`schema.ts`, `db/index.ts`, a drizzle migration per dialect, (`schema.ts`, `db/index.ts`, a drizzle migration per dialect,
`schema:generate`) needs every one of those switched first. Owner: a `schema:generate`) needs every one of those switched first. Owner: a
dedicated follow-up step, or D0. dedicated follow-up step, or D0.
- **B13 (session-recording):** `remote-desktop`'s guacd recordings now insert
through the optional `recordings.writer` service's `createFinished(input)`
(added alongside `open()`/`RecordingSink` for ssh-terminal's incremental
writer) instead of importing `createCurrentSessionRecordingRepository`
directly. Without the session-recording plugin enabled, a guacd recording
is still written to disk but gets no `session_recordings` row and so never
appears in the list - a warning is logged. Owner: none needed unless a
second consumer of `recordings.writer` appears.
- **B13 (session-recording):** `accessId` (the legacy `access_id` column, - **B13 (session-recording):** `accessId` (the legacy `access_id` column,
referencing the now-core-only `host_access` table) is carried in the referencing the now-core-only `host_access` table) is carried in the
adopted table's shape as a plain unreferenced integer, since the SDK's adopted table's shape as a plain unreferenced integer, since the SDK's
@@ -317,6 +295,56 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
in core before this step too). Owner: none needed unless a future step in core before this step too). Owner: none needed unless a future step
wants to actually use it. wants to actually use it.
- **B14 (remote-desktop):** the moved columns stay in the database, unused:
`ssh_data.enable_rdp`, `enable_vnc`, `enable_telnet`, `rdp_port`,
`vnc_port`, `telnet_port`, `rdp_security`, `rdp_ignore_cert`, `security`,
`ignore_cert`, `guacamole_config`, and `user_preferences.rdp_defaults`.
Their drizzle migrations are `SELECT 1;` on purpose (the copy runs after
drizzle on Postgres and MySQL). Drop them physically in 3.0.0, once every
install has booted 2.9.0. The legacy `guac_enabled` and `guac_url` rows in
`settings` are left too. Owner: 3.0.0.
- **B14, found in B5 (proxmox):** `drizzle/postgres/0036_redundant_ender_wiggin.sql`
and `drizzle/mysql/0035_clumsy_leech.sql` really `DROP COLUMN` the four
proxmox host columns. On Postgres and MySQL that runs at boot before
`runProxmoxSettingsMigration` copies them, so an upgrade there loses every
host's Proxmox settings. Since nothing has shipped, rewrite those two files
to the `SELECT 1;` pattern (keeping their journal entries). Owner: D0.
- **B14, found in B5 to B13:** every earlier host-column copy
(`proxmox`, `file-manager`, `tunnels`, `web-endpoint`, `ssh-terminal`,
`tmux-monitor`, `session-sharing`, `session-recording` settings migrations)
reads the old columns with `getDb().all(sql...)`, which only exists on
SQLite. On Postgres and MySQL the call throws, the migration's catch logs a
warning and nothing is copied. Switch them to `selectRows` from
`utils/crypto-migration/raw-rows.ts`, which B14 added and uses. Owner: D0.
- **B14, found in B13:** `session-recording-settings-migration.ts` writes
`row.enable_session_logging !== false`, but SQLite returns `0`, so a host
that had recording off migrates as on. Its test only uses real booleans.
Owner: D0.
- **B14 (remote-desktop), for D2:** host payloads carry every plugin's host
settings after the sharing sanitizers, so a connect-level recipient now
receives remote desktop's `guacamoleConfig`, which can hold an RDP gateway
password in plain text. A view-level recipient saw it before B14 too.
Decide whether plugin host settings need a per-field sharing level, or make
the gateway password its own secret field. Owner: D2.
- **B14 (remote-desktop):** core still holds some locale keys that look dead
after this step but may be built dynamically: `hosts.guac.noCredential`,
`authType*`, `recording*`, `saveHostFirst`, `sharingOptionsAfterSave`,
`permissionLevel`, `typeHeader`, `targetHeader`, `permissionHeader`,
`hosts.connectRdp/Vnc/Telnet`, `hosts.copy*UrlAction`, `hosts.telnet` and
`homepage.connType_*`. Owner: whoever next cleans up `src/ui/locales/en.json`,
or D0.
- **Pre-existing, found in B14:** `npm run test -w plugins/web-endpoint`
fails "answers 503 while the tunnels plugin is not available": the route
expects `ctx.services.get` to throw for a missing service, but since B12
the runtime and the test doubles hand back an empty handle, so the route
calls an undefined `forward` and answers 500. Check `typeof forward` the
way remote desktop does. Owner: D0.
- **Pre-existing, found in B14:** each plugin's own `typecheck` script
(`tsc -p tsconfig.json`) fails with the SDK's `tsconfig.plugin.json`
(node16 resolution wants `.js` on relative imports and cannot resolve
`@termix/plugin-sdk/ui`), workspaces included. `npm run type-check` covers
plugins through `tsconfig.plugins*.json` and passes. Owner: D0.
## Manual checks after 2.9.0 ## Manual checks after 2.9.0
- SSH terminal: password auth, key auth (and an encrypted key's passphrase - SSH terminal: password auth, key auth (and an encrypted key's passphrase
@@ -334,3 +362,15 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
off records nothing, retention prunes an old recording on schedule, a guacd off records nothing, retention prunes an old recording on schedule, a guacd
RDP/VNC/Telnet recording still gets a row and plays back, and deleting a RDP/VNC/Telnet recording still gets a row and plays back, and deleting a
user anonymizes their recordings instead of removing them. user anonymizes their recordings instead of removing them.
- Remote desktop: an RDP, a VNC and a Telnet host; a host behind one jump
host (tunnels plugin on and off) and behind a chain of two; macOS Screen
Sharing over VNC with no username; RDP recording (and none with
session-recording off or the host's switch off); file transfer through the
RDP drive, drag and drop included; clipboard both ways; sharing an RDP
session by link (read-only and read-write) and presenting it in a collab
room; opening Windows Remote Desktop from the desktop app on Windows;
changing the guacd URL in admin settings without a restart; turning Remote
Desktop off in admin (actions disappear, connects answer 403); a 2.8 host
that only had `connection_type = rdp` comes up as RDP with SSH off; RDP user
defaults apply to a saved host; Quick Connect over RDP and VNC; the desktop
app with a host on the remote server and one on This device.
@@ -510,6 +510,10 @@
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"description": "Registered component id. Required when type is \"custom\"." "description": "Registered component id. Required when type is \"custom\"."
},
"hidden": {
"type": "boolean",
"description": "Stored and validated, but not drawn by the generic settings form."
} }
} }
} }
@@ -602,6 +606,10 @@
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"description": "Registered component id. Required when type is \"custom\"." "description": "Registered component id. Required when type is \"custom\"."
},
"hidden": {
"type": "boolean",
"description": "Stored and validated, but not drawn by the generic settings form."
} }
} }
} }
+67 -3
View File
@@ -477,7 +477,6 @@ export interface PluginHostRecord {
folder: string | null; folder: string | null;
jumpHosts?: unknown; jumpHosts?: unknown;
enableSsh?: boolean | null; enableSsh?: boolean | null;
enableRdp?: boolean | null;
enableTerminal?: boolean | null; enableTerminal?: boolean | null;
enableFileManager?: boolean | null; enableFileManager?: boolean | null;
enableTunnel?: boolean | null; enableTunnel?: boolean | null;
@@ -487,7 +486,11 @@ export interface PluginHostRecord {
[key: string]: unknown; [key: string]: unknown;
} }
/** Fields a plugin may set when creating a host it will own or manage. */ /**
* Fields a plugin may set when creating a host it will own or manage. Fields
* that belong to another plugin's host settings (enableRdp, rdpPort) are
* handed to that plugin's host import normalizer, as on a bulk import.
*/
export type PluginHostCreateInput = Partial< export type PluginHostCreateInput = Partial<
Omit<PluginHostRecord, "id" | "userId"> Omit<PluginHostRecord, "id" | "userId">
> & { > & {
@@ -1116,10 +1119,69 @@ export interface PluginOpenIsolatedWindowRequest {
* Electron only: rejects when the server is not running embedded in the * Electron only: rejects when the server is not running embedded in the
* desktop app. Needs desktop:window. * desktop app. Needs desktop:window.
*/ */
export interface PluginNativeRdpRequest {
host: string;
port?: number;
username?: string;
domain?: string;
}
export interface PluginDesktop { export interface PluginDesktop {
openIsolatedWindow: ( openIsolatedWindow: (
request: PluginOpenIsolatedWindowRequest, request: PluginOpenIsolatedWindowRequest,
) => Promise<{ success: true }>; ) => Promise<{ success: true }>;
/**
* Opens the operating system's own RDP client (mstsc on Windows) for a
* host. The password is never passed; the client asks for it. Windows
* desktop app only.
*/
launchNativeRdp: (
request: PluginNativeRdpRequest,
) => Promise<{ success: boolean; error?: string }>;
/** Whether the server runs embedded in the desktop app, so the calls above can work. */
available: () => boolean;
}
/** A host protocol whose credentials core keeps next to the host. */
export type PluginHostProtocol = "rdp" | "vnc" | "telnet";
/**
* What a plugin needs to hand a host's protocol login to something core
* does not run (guacd): the address and the plaintext credentials the acting
* user is allowed to use. A shared recipient gets the owner's shared
* snapshot or their own override, never the owner's raw secret.
*/
export interface PluginProtocolTarget {
host: {
id: number;
name: string | null;
ip: string;
/** The host's SSH port, the old fallback when no protocol port is set. */
port: number;
ownerUserId: string;
jumpHosts: Array<{ hostId: number }>;
};
/** True when the acting user is not the owner. */
shared: boolean;
auth: {
/** "direct", "credential", or "none" when the user is asked at connect time. */
authType: string;
username: string;
password: string;
domain: string;
};
}
/**
* Plaintext host credentials, for a plugin that has to hand them to another
* program. Needs credentials:read, and every call is audited.
*/
export interface PluginCredentials {
/** Null when the host does not exist or the acting user cannot connect to it. */
resolveHostProtocol: (
hostId: number,
protocol: PluginHostProtocol,
) => Promise<PluginProtocolTarget | null>;
} }
/** /**
@@ -1145,7 +1207,7 @@ export interface PluginAudit {
* hardware (a serial port, a USB device) is the first caller. Core cannot * hardware (a serial port, a USB device) is the first caller. Core cannot
* mediate that access the way it mediates ctx.ssh or ctx.db, so the * mediate that access the way it mediates ctx.ssh or ctx.db, so the
* capability here is a declared, reviewable, audited statement of intent * capability here is a declared, reviewable, audited statement of intent
* rather than a technical gate, matching credentials:read and process:spawn. * rather than a technical gate, matching process:spawn.
*/ */
export interface PluginCapabilities { export interface PluginCapabilities {
/** Whether this plugin currently holds `capability`. Not audited: for deciding whether to offer something, not for gating an action. */ /** Whether this plugin currently holds `capability`. Not audited: for deciding whether to offer something, not for gating an action. */
@@ -1181,6 +1243,8 @@ export interface PluginContext {
readonly auth: PluginAuth; readonly auth: PluginAuth;
/** Opens Electron windows outside the main renderer. Needs desktop:window. */ /** Opens Electron windows outside the main renderer. Needs desktop:window. */
readonly desktop: PluginDesktop; readonly desktop: PluginDesktop;
/** Plaintext host protocol credentials. Needs credentials:read. */
readonly credentials: PluginCredentials;
/** Audit lines under the plugin's own action names. */ /** Audit lines under the plugin's own action names. */
readonly audit: PluginAudit; readonly audit: PluginAudit;
+36
View File
@@ -317,6 +317,26 @@ export interface HostActionContribution {
}[]; }[];
} }
/**
* A connection protocol next to SSH, shown as a switch in the host editor's
* General tab and in host filters and Quick Connect. Its switch and port are
* this plugin's own host settings (declare both in contributes.settings.host).
*/
export interface HostProtocolContribution {
id: string;
/** Boolean host setting that turns the protocol on. */
settingKey: string;
/** Number host setting holding the port. */
portKey?: string;
defaultPort: number;
titleKey: string;
descriptionKey?: string;
icon: IconComponent;
order?: number;
/** Offer it in Quick Connect, optionally with a domain field. */
quickConnect?: { showDomain?: boolean };
}
export interface HostBadgeContribution { export interface HostBadgeContribution {
id: string; id: string;
when: (host: PluginHostRecord) => boolean; when: (host: PluginHostRecord) => boolean;
@@ -524,6 +544,7 @@ export interface TermixApp extends TermixAppInfo {
section: HostEditorSectionContribution, section: HostEditorSectionContribution,
) => Disposer; ) => Disposer;
registerHostAction: (action: HostActionContribution) => Disposer; registerHostAction: (action: HostActionContribution) => Disposer;
registerHostProtocol: (protocol: HostProtocolContribution) => Disposer;
registerHostBadge: (badge: HostBadgeContribution) => Disposer; registerHostBadge: (badge: HostBadgeContribution) => Disposer;
registerHostContextMenuItem: ( registerHostContextMenuItem: (
item: HostContextMenuItemContribution, item: HostContextMenuItemContribution,
@@ -574,6 +595,11 @@ export interface TermixApp extends TermixAppInfo {
/** HTTP client for this plugin's /plugin-api/<id>/ routes. */ /** HTTP client for this plugin's /plugin-api/<id>/ routes. */
api: PluginApiClient; api: PluginApiClient;
/**
* The same client for a resolved connection origin: "remote" reaches the
* desktop app's connected remote server, anything else is `api`.
*/
apiFor: (origin?: unknown) => PluginApiClient;
/** WebSocket URL and auth subprotocols for /plugin-ws/<id>/<path>. */ /** WebSocket URL and auth subprotocols for /plugin-ws/<id>/<path>. */
wsUrl: ( wsUrl: (
path: string, path: string,
@@ -668,6 +694,7 @@ export interface PluginHostBridge {
usePluginComponent: ( usePluginComponent: (
id: string, id: string,
) => ComponentType<Record<string, unknown>> | undefined; ) => ComponentType<Record<string, unknown>> | undefined;
hostProtocols: (host: PluginHostRecord) => string[];
} }
let host: PluginHostBridge | null = null; let host: PluginHostBridge | null = null;
@@ -778,6 +805,15 @@ export function useSshAuthTypes(): {
* next to its own. Same permission and `when` filtering as ComponentSlot; * next to its own. Same permission and `when` filtering as ComponentSlot;
* `context` is what each contribution's `when` sees. * `context` is what each contribution's `when` sees.
*/ */
/**
* The connection protocols a host has switched on: "ssh" plus every protocol
* a running plugin registered with registerHostProtocol (RDP, VNC). Lets a
* plugin offer a protocol without knowing which plugin owns its settings.
*/
export function hostProtocols(record: PluginHostRecord): string[] {
return requireHost().hostProtocols(record);
}
export function useSlotContributions( export function useSlotContributions(
slotId: string, slotId: string,
context?: Record<string, unknown>, context?: Record<string, unknown>,
+10
View File
@@ -224,6 +224,12 @@ export interface PluginSettingsField {
group?: string; group?: string;
/** Registered component id. Required when type is "custom". */ /** Registered component id. Required when type is "custom". */
component?: string; component?: string;
/**
* Stored and validated like any field, but not drawn by the generic
* settings form, because the plugin edits it in its own UI (a host editor
* section).
*/
hidden?: boolean;
} }
export interface PluginHostSettingsContribution { export interface PluginHostSettingsContribution {
@@ -382,6 +388,7 @@ const ALLOWED_SETTINGS_FIELD = [
"permission", "permission",
"group", "group",
"component", "component",
"hidden",
]; ];
/** Settings keys are stored as-is, so they stay short and index-safe. */ /** Settings keys are stored as-is, so they stay short and index-safe. */
@@ -933,6 +940,9 @@ function validateSettingsFields(
if ("permission" in raw) { if ("permission" in raw) {
requireString(raw.permission, `${at}.permission`, errors); requireString(raw.permission, `${at}.permission`, errors);
} }
if ("hidden" in raw && typeof raw.hidden !== "boolean") {
errors.push(`${at}.hidden must be a boolean`);
}
if (raw.type === "select" || raw.type === "multiselect") { if (raw.type === "select" || raw.type === "multiselect") {
validateSettingsOptions(raw.options, at, errors); validateSettingsOptions(raw.options, at, errors);
+50
View File
@@ -35,6 +35,10 @@ import type {
} from "./backend.js"; } from "./backend.js";
import type { SyncEntityRegistration } from "./backend.js"; import type { SyncEntityRegistration } from "./backend.js";
import type { PluginDatabase } from "./backend.js"; import type { PluginDatabase } from "./backend.js";
import type {
PluginNativeRdpRequest,
PluginProtocolTarget,
} from "./backend.js";
export interface FakeContextOptions { export interface FakeContextOptions {
pluginId?: string; pluginId?: string;
@@ -69,6 +73,13 @@ export interface FakeContextOptions {
shareableRoles?: PluginShareableRole[]; shareableRoles?: PluginShareableRole[];
/** Hosts ctx.ssh.resolveHost answers with, secrets included. */ /** Hosts ctx.ssh.resolveHost answers with, secrets included. */
sshHosts?: PluginSshHost[]; sshHosts?: PluginSshHost[];
/**
* What ctx.credentials.resolveHostProtocol answers, keyed
* "<hostId>:<protocol>". A missing key answers null.
*/
protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.desktop.available() answers. Defaults to false. */
desktopAvailable?: boolean;
} }
export interface FakeAuthRegistrations { export interface FakeAuthRegistrations {
@@ -123,6 +134,10 @@ export interface FakePluginContext {
title?: string; title?: string;
ignoreCert?: boolean; ignoreCert?: boolean;
}>; }>;
/** Every ctx.desktop.launchNativeRdp call, in order. */
nativeRdpLaunches: PluginNativeRdpRequest[];
/** Every ctx.credentials.resolveHostProtocol call, in order. */
credentialReads: Array<{ hostId: number; protocol: string }>;
/** Every ctx.audit.record entry, in order. */ /** Every ctx.audit.record entry, in order. */
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>; audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
/** Every ctx.hosts.recordActivity call, in order. */ /** Every ctx.hosts.recordActivity call, in order. */
@@ -206,6 +221,8 @@ export function createFakeContext(
const sshConnections: FakePluginContext["sshConnections"] = []; const sshConnections: FakePluginContext["sshConnections"] = [];
const hostShares: FakePluginContext["hostShares"] = []; const hostShares: FakePluginContext["hostShares"] = [];
const desktopWindows: FakePluginContext["desktopWindows"] = []; const desktopWindows: FakePluginContext["desktopWindows"] = [];
const nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = [];
const credentialReads: FakePluginContext["credentialReads"] = [];
const audits: FakePluginContext["audits"] = []; const audits: FakePluginContext["audits"] = [];
const activities: FakePluginContext["activities"] = []; const activities: FakePluginContext["activities"] = [];
const trackedSessions: number[] = []; const trackedSessions: number[] = [];
@@ -635,6 +652,18 @@ export function createFakeContext(
desktopWindows.push(request); desktopWindows.push(request);
return { success: true }; return { success: true };
}, },
launchNativeRdp: async (request) => {
nativeRdpLaunches.push(request);
return { success: true };
},
available: () => options.desktopAvailable ?? false,
},
credentials: {
resolveHostProtocol: async (hostId, protocol) => {
credentialReads.push({ hostId, protocol });
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
},
}, },
asUser: async (userId, fn) => { asUser: async (userId, fn) => {
@@ -666,6 +695,8 @@ export function createFakeContext(
hostShares, hostShares,
auth, auth,
desktopWindows, desktopWindows,
nativeRdpLaunches,
credentialReads,
audits, audits,
activities, activities,
trackedSessions, trackedSessions,
@@ -704,6 +735,10 @@ export interface MockContextOptions {
services?: Record<string, object>; services?: Record<string, object>;
/** Hosts ctx.ssh.resolveHost serves. See FakeContextOptions. */ /** Hosts ctx.ssh.resolveHost serves. See FakeContextOptions. */
sshHosts?: PluginSshHost[]; sshHosts?: PluginSshHost[];
/** What ctx.credentials serves. See FakeContextOptions. */
protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.desktop.available() answers. */
desktopAvailable?: boolean;
} }
export interface MockPluginContext extends FakePluginContext { export interface MockPluginContext extends FakePluginContext {
@@ -740,6 +775,8 @@ export function createMockCtx(
hosts: options.hosts, hosts: options.hosts,
sshHosts: options.sshHosts, sshHosts: options.sshHosts,
services: options.services, services: options.services,
protocolTargets: options.protocolTargets,
desktopAvailable: options.desktopAvailable,
manifest: { manifest: {
capabilities: options.capabilities ?? [], capabilities: options.capabilities ?? [],
...options.manifest, ...options.manifest,
@@ -972,6 +1009,18 @@ export function createMockCtx(
require("desktop:window"); require("desktop:window");
return ctx.desktop.openIsolatedWindow(request); return ctx.desktop.openIsolatedWindow(request);
}, },
launchNativeRdp: async (request) => {
require("desktop:window");
return ctx.desktop.launchNativeRdp(request);
},
available: () => ctx.desktop.available(),
},
credentials: {
resolveHostProtocol: async (hostId, protocol) => {
require("credentials:read");
return ctx.credentials.resolveHostProtocol(hostId, protocol);
},
}, },
capabilities: { capabilities: {
@@ -1180,6 +1229,7 @@ export interface RenderedPluginApp {
tabs: () => string[]; tabs: () => string[];
panels: () => string[]; panels: () => string[];
hostActions: () => Array<{ id: string; tabType?: string }>; hostActions: () => Array<{ id: string; tabType?: string }>;
hostProtocols: () => string[];
hostEditorSections: () => string[]; hostEditorSections: () => string[];
dashboardCards: () => string[]; dashboardCards: () => string[];
settingsComponents: () => string[]; settingsComponents: () => string[];
@@ -300,7 +300,6 @@ function ConsoleTerminalInner({
const origin = isElectronApp const origin = isElectronApp
? await resolveConnectionOrigin({ ? await resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: hostConfig.connectionOrigin, connectionOrigin: hostConfig.connectionOrigin,
}) })
: "local"; : "local";
@@ -982,7 +982,6 @@ export async function ensureSSHSessionForHost(
): Promise<EnsureSSHSessionResult> { ): Promise<EnsureSSHSessionResult> {
const sessionId = host.id.toString(); const sessionId = host.id.toString();
const origin = await resolveConnectionOrigin({ const origin = await resolveConnectionOrigin({
connectionType: host.connectionType,
connectionOrigin: host.connectionOrigin, connectionOrigin: host.connectionOrigin,
}); });
setSessionOrigin(sessionId, origin); setSessionOrigin(sessionId, origin);
-4
View File
@@ -652,15 +652,11 @@ async function syncProxmoxHost(
rdpUser: null, rdpUser: null,
rdpPassword: null, rdpPassword: null,
rdpDomain: null, rdpDomain: null,
rdpSecurity: null,
rdpIgnoreCert: 0,
rdpPort: connectionType === "rdp" ? 3389 : null, rdpPort: connectionType === "rdp" ? 3389 : null,
vncUser: null, vncUser: null,
vncPassword: null, vncPassword: null,
vncPort: null,
telnetUser: null, telnetUser: null,
telnetPassword: null, telnetPassword: null,
telnetPort: null,
defaultPath: "/", defaultPath: "/",
tunnelConnections: "[]", tunnelConnections: "[]",
jumpHosts: serializeProxmoxJumpHosts(discovery.jumpHosts), jumpHosts: serializeProxmoxJumpHosts(discovery.jumpHosts),
+12
View File
@@ -3,3 +3,15 @@
## 1.0.0 ## 1.0.0
Bundled with Termix 2.9.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.
+46 -54
View File
@@ -1,68 +1,60 @@
# Remote Desktop # 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 guacd (the Apache Guacamole proxy daemon that speaks RDP, VNC and Telnet) is an
through a structured-clone `postMessage` boundary. That boundary is what makes external service this plugin talks to over TCP. The stock Docker compose runs
the capability gate and the audit trail mean anything: a worker plugin cannot it as its own `guacamole/guacd` container. This plugin never starts it.
hold a db handle, an ssh2 `Client` or a live socket, so every privileged action
has to go through the broker.
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, Other environment variables the plugin reads:
- 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.
None of that can cross a structured-clone boundary, so this plugin runs on the | Variable | What it sets |
main thread instead, under the `process:transport-owner` tier, the same as | ------------------------------ | --------------------------------------------------------------------------- |
`ssh-terminal`, `docker` and `host-metrics`. | `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) - **Admin**: turn Remote Desktop on or off, and the guacd URL.
is a separate, external service reached over plain TCP -- by default a - **User**: RDP defaults (colour depth, resize method, wallpaper, clipboard
sibling Docker container, or any network-reachable host configured through the and the rest). A host's own values win; anything a host leaves on its
admin "guacd URL" setting. This plugin does not download, bundle or spawn default takes the user's.
guacd as a child process. - **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 The logins (users, passwords, stored credentials, domain) stay on the host in
publish prebuilt guacd binaries for any platform (only source tarballs and core, because sharing decides which of them a recipient may use.
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.
## What lives where ## Capabilities
Unlike `ssh-terminal` (whose implementation stays in core because - `credentials:read`: guacd needs the host's RDP, VNC or Telnet password in
`session-manager.ts` has consumers well beyond the terminal), this plugin's plain text, so the plugin reads it through `ctx.credentials`. A shared
backend and frontend code is fully relocated here from recipient only ever gets the owner's shared snapshot or their own override.
`src/backend/hosts/guacamole/` and `src/ui/features/guacamole/`, the same - `ssh:connect`, `credentials:use`: jump host tunnels, through
shape `docker` and `host-metrics` use. `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 ## Services
into this plugin rather than living in core:
- `src/backend/hosts/collab/routes.ts` and Provides `sessions.live` under the names `rdp`, `vnc` and `telnet`, which is
`src/backend/hosts/session-sharing/` import `token-service.ts` and how session sharing and collab rooms find a live session and mint a viewer
`guacamole-server.ts` directly to resolve and validate live Guacamole token for it. Uses `recordings.writer` (session-recording) to decide whether
sessions for collaborative/shared viewing. to record and to list finished recordings, and `tunnels.access` (tunnels)
- `src/ui/features/collab/` and `src/ui/features/session-sharing/` import for single-hop jump tunnels. Both are optional.
`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.
+127 -3
View File
@@ -73,7 +73,8 @@
"fitToScreen": "Fit to screen", "fitToScreen": "Fit to screen",
"switchToTrackpad": "Switch to trackpad mode (drag to move cursor, tap to click)", "switchToTrackpad": "Switch to trackpad mode (drag to move cursor, tap to click)",
"switchToTouch": "Switch to touch mode (tap directly where you want 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": { "hosts": {
"nativeRdpOpened": "Opened in Windows Remote Desktop", "nativeRdpOpened": "Opened in Windows Remote Desktop",
@@ -204,11 +205,134 @@
"fontName": "Font Name", "fontName": "Font Name",
"fontSize": "Font Size", "fontSize": "Font Size",
"colorScheme": "Color Scheme", "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": { "onboarding": {
"feature_desktop": "Remote desktop", "feature_desktop": "Remote desktop",
"feature_desktop_desc": "Open RDP, VNC and Telnet sessions in a tab, same as SSH." "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", "fontName": "Lettertipe Naam",
"fontSize": "Lettergrootte", "fontSize": "Lettergrootte",
"colorScheme": "Kleurskema", "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": { "onboarding": {
"feature_desktop": "Afstandlessenaar", "feature_desktop": "Afstandlessenaar",
"feature_desktop_desc": "Maak RDP-, VNC- en Telnet-sessies in 'n oortjie oop, dieselfde as SSH." "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": "اسم الخط", "fontName": "اسم الخط",
"fontSize": "حجم الخط", "fontSize": "حجم الخط",
"colorScheme": "نظام الألوان", "colorScheme": "نظام الألوان",
"backspaceKey": "مفتاح Backspace" "backspaceKey": "مفتاح Backspace",
} "colorDepth": "عمق اللون",
"resizeMethod": "طريقة تغيير الحجم"
},
"remoteDesktop": "سطح المكتب البعيد",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "الشبكة الافتراضية",
"unencryptedShell": "shell غير مشفر"
}, },
"onboarding": { "onboarding": {
"feature_desktop": "سطح المكتب البعيد", "feature_desktop": "سطح المكتب البعيد",
"feature_desktop_desc": "افتح جلسات RDP وVNC وTelnet في علامة تبويب، تمامًا مثل SSH." "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": "Име на шрифта", "fontName": "Име на шрифта",
"fontSize": "Размер на шрифта", "fontSize": "Размер на шрифта",
"colorScheme": "Цветова схема", "colorScheme": "Цветова схема",
"backspaceKey": "Клавиш Backspace" "backspaceKey": "Клавиш Backspace",
} "colorDepth": "Дълбочина на цвета",
"resizeMethod": "Метод за преоразмеряване"
},
"remoteDesktop": "Отдалечен работен плот",
"tabRdp": "ПРСР",
"tabVnc": "VNC",
"tabTelnet": "Телнет",
"virtualNetwork": "Виртуална мрежа",
"unencryptedShell": "Некриптирана обвивка"
}, },
"onboarding": { "onboarding": {
"feature_desktop": "Отдалечен работен плот", "feature_desktop": "Отдалечен работен плот",
"feature_desktop_desc": "Отваряне на RDP, VNC и Telnet сесии в раздел, както при SSH." "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": "ফন্টের নাম", "fontName": "ফন্টের নাম",
"fontSize": "ফন্ট সাইজ", "fontSize": "ফন্ট সাইজ",
"colorScheme": "রঙের পরিকল্পনা", "colorScheme": "রঙের পরিকল্পনা",
"backspaceKey": "ব্যাকস্পেস কী" "backspaceKey": "ব্যাকস্পেস কী",
} "colorDepth": "রঙের গভীরতা",
"resizeMethod": "আকার পরিবর্তন পদ্ধতি"
},
"remoteDesktop": "রিমোট ডেস্কটপ",
"tabRdp": "আরডিপি",
"tabVnc": "ভিএনসি",
"tabTelnet": "টেলনেট",
"virtualNetwork": "ভার্চুয়াল নেটওয়ার্ক",
"unencryptedShell": "এনক্রিপ্টবিহীন শেল"
}, },
"onboarding": { "onboarding": {
"feature_desktop": "রিমোট ডেস্কটপ", "feature_desktop": "রিমোট ডেস্কটপ",
"feature_desktop_desc": "SSH-এর মতোই একটি ট্যাবে RDP, VNC এবং Telnet সেশন খুলুন।" "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", "fontName": "Nom de la font",
"fontSize": "Mida de la lletra", "fontSize": "Mida de la lletra",
"colorScheme": "Esquema de colors", "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": { "onboarding": {
"feature_desktop": "Escriptori remot", "feature_desktop": "Escriptori remot",
"feature_desktop_desc": "Obriu sessions RDP, VNC i Telnet en una pestanya, igual que SSH." "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", "fontName": "Název písma",
"fontSize": "Velikost písma", "fontSize": "Velikost písma",
"colorScheme": "Barevné schéma", "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": { "onboarding": {
"feature_desktop": "Vzdálená plocha", "feature_desktop": "Vzdálená plocha",
"feature_desktop_desc": "Otevírejte relace RDP, VNC a Telnet na kartě, stejně jako SSH." "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", "fontName": "Skrifttypenavn",
"fontSize": "Skriftstørrelse", "fontSize": "Skriftstørrelse",
"colorScheme": "Farveskema", "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": { "onboarding": {
"feature_desktop": "Fjernskrivebord", "feature_desktop": "Fjernskrivebord",
"feature_desktop_desc": "Åbn RDP-, VNC- og Telnet-sessioner i en fane, ligesom med SSH." "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", "fontName": "Schriftname",
"fontSize": "Schriftgröße", "fontSize": "Schriftgröße",
"colorScheme": "Farbschema", "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": { "onboarding": {
"feature_desktop": "Remote-Desktop", "feature_desktop": "Remote-Desktop",
"feature_desktop_desc": "Öffnen Sie RDP-, VNC- und Telnet-Sitzungen in einem Tab, genau wie bei SSH." "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": "Όνομα γραμματοσειράς", "fontName": "Όνομα γραμματοσειράς",
"fontSize": "Μέγεθος γραμματοσειράς", "fontSize": "Μέγεθος γραμματοσειράς",
"colorScheme": "Χρωματικό σχήμα", "colorScheme": "Χρωματικό σχήμα",
"backspaceKey": "Πλήκτρο Backspace" "backspaceKey": "Πλήκτρο Backspace",
} "colorDepth": "Βάθος χρώματος",
"resizeMethod": "Μέθοδος αλλαγής μεγέθους"
},
"remoteDesktop": "Απομακρυσμένη επιφάνεια εργασίας",
"tabRdp": "ΠΑΑ",
"tabVnc": "VNC",
"tabTelnet": "Τέλνετ",
"virtualNetwork": "Εικονικό Δίκτυο",
"unencryptedShell": "Μη κρυπτογραφημένο κέλυφος"
}, },
"onboarding": { "onboarding": {
"feature_desktop": "Απομακρυσμένη επιφάνεια εργασίας", "feature_desktop": "Απομακρυσμένη επιφάνεια εργασίας",
"feature_desktop_desc": "Ανοίξτε τις συνεδρίες RDP, VNC και Telnet σε μια καρτέλα, όπως και το SSH." "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", "fontName": "Nombre de fuente",
"fontSize": "Tamaño de fuente", "fontSize": "Tamaño de fuente",
"colorScheme": "Esquema de color", "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": { "onboarding": {
"feature_desktop": "Escritorio remoto", "feature_desktop": "Escritorio remoto",
"feature_desktop_desc": "Abre sesiones RDP, VNC y Telnet en una pestaña, igual que con SSH." "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", "fontName": "Fontin nimi",
"fontSize": "Fonttikoko", "fontSize": "Fonttikoko",
"colorScheme": "Värimaailma", "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": { "onboarding": {
"feature_desktop": "Etätyöpöytä", "feature_desktop": "Etätyöpöytä",
"feature_desktop_desc": "Avaa RDP-, VNC- ja Telnet-istunnot välilehdellä, samalla tavalla kuin SSH:ssa." "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", "fontName": "Nom de la police",
"fontSize": "Taille de la police", "fontSize": "Taille de la police",
"colorScheme": "Schéma de couleurs", "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": { "onboarding": {
"feature_desktop": "Bureau à distance", "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." "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": "שם הגופן", "fontName": "שם הגופן",
"fontSize": "גודל גופן", "fontSize": "גודל גופן",
"colorScheme": "ערכת צבעים", "colorScheme": "ערכת צבעים",
"backspaceKey": "מקש Backspace" "backspaceKey": "מקש Backspace",
} "colorDepth": "עומק צבע",
"resizeMethod": "שיטת שינוי גודל"
},
"remoteDesktop": "שולחן עבודה מרוחק",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "טלנט",
"virtualNetwork": "רשת וירטואלית",
"unencryptedShell": "מעטפת לא מוצפנת"
}, },
"onboarding": { "onboarding": {
"feature_desktop": "שולחן עבודה מרוחק", "feature_desktop": "שולחן עבודה מרוחק",
"feature_desktop_desc": "פתחו הפעלות RDP, VNC ו-Telnet בכרטיסייה, בדומה ל-SSH." "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": "फ़ॉन्ट नाम", "fontName": "फ़ॉन्ट नाम",
"fontSize": "फ़ॉन्ट आकार", "fontSize": "फ़ॉन्ट आकार",
"colorScheme": "रंग योजना", "colorScheme": "रंग योजना",
"backspaceKey": "बैकस्पेस कुंजी" "backspaceKey": "बैकस्पेस कुंजी",
} "colorDepth": "रंग गहराई",
"resizeMethod": "रीसाइज़ विधि"
},
"remoteDesktop": "दूरस्थ डेस्कटॉप",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "वर्चुअल नेटवर्क",
"unencryptedShell": "अनएन्क्रिप्टेड शेल"
}, },
"onboarding": { "onboarding": {
"feature_desktop": "दूरवर्ती डेस्कटॉप", "feature_desktop": "दूरवर्ती डेस्कटॉप",
"feature_desktop_desc": "RDP, VNC और Telnet सेशन को SSH की तरह ही एक टैब में खोलें।" "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", "fontName": "Betűtípus neve",
"fontSize": "Betűméret", "fontSize": "Betűméret",
"colorScheme": "Színséma", "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": { "onboarding": {
"feature_desktop": "Távoli asztal", "feature_desktop": "Távoli asztal",
"feature_desktop_desc": "Nyisson meg RDP, VNC és Telnet munkameneteket egy lapon, ugyanúgy, mint az SSH-n." "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", "fontName": "Nama Font",
"fontSize": "Ukuran Huruf", "fontSize": "Ukuran Huruf",
"colorScheme": "Skema Warna", "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": { "onboarding": {
"feature_desktop": "Desktop jarak jauh", "feature_desktop": "Desktop jarak jauh",
"feature_desktop_desc": "Buka sesi RDP, VNC, dan Telnet dalam satu tab, sama seperti SSH." "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", "fontName": "Nome font",
"fontSize": "Dimensione font", "fontSize": "Dimensione font",
"colorScheme": "Schema colori", "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": { "onboarding": {
"feature_desktop": "Desktop remoto", "feature_desktop": "Desktop remoto",
"feature_desktop_desc": "Apri le sessioni RDP, VNC e Telnet in una scheda, proprio come per SSH." "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": "フォント名", "fontName": "フォント名",
"fontSize": "フォントサイズ", "fontSize": "フォントサイズ",
"colorScheme": "カラースキーム", "colorScheme": "カラースキーム",
"backspaceKey": "バックスペースキー" "backspaceKey": "バックスペースキー",
} "colorDepth": "色深度",
"resizeMethod": "リサイズ方法"
},
"remoteDesktop": "リモートデスクトップ",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "仮想ネットワーク",
"unencryptedShell": "暗号化されていないシェル"
}, },
"onboarding": { "onboarding": {
"feature_desktop": "リモートデスクトップ", "feature_desktop": "リモートデスクトップ",
"feature_desktop_desc": "RDP、VNC、Telnetのセッションは、SSHと同様にタブで開きます。" "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": "글꼴 이름", "fontName": "글꼴 이름",
"fontSize": "글꼴 크기", "fontSize": "글꼴 크기",
"colorScheme": "색 구성표", "colorScheme": "색 구성표",
"backspaceKey": "백스페이스 키" "backspaceKey": "백스페이스 키",
} "colorDepth": "색 농도",
"resizeMethod": "크기 조정 방법"
},
"remoteDesktop": "원격 데스크톱",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "텔넷",
"virtualNetwork": "가상 네트워크",
"unencryptedShell": "암호화되지 않은 셸"
}, },
"onboarding": { "onboarding": {
"feature_desktop": "원격 데스크톱", "feature_desktop": "원격 데스크톱",
"feature_desktop_desc": "SSH와 마찬가지로 RDP, VNC 및 Telnet 세션을 탭에서 열 수 있습니다." "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", "fontName": "Lettertypenaam",
"fontSize": "Lettergrootte", "fontSize": "Lettergrootte",
"colorScheme": "Kleurenschema", "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": { "onboarding": {
"feature_desktop": "Extern bureaublad", "feature_desktop": "Extern bureaublad",
"feature_desktop_desc": "Open RDP-, VNC- en Telnet-sessies in een tabblad, net als bij SSH." "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", "fontName": "Skriftnavn",
"fontSize": "Skriftstørrelse", "fontSize": "Skriftstørrelse",
"colorScheme": "Fargeskjema", "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": { "onboarding": {
"feature_desktop": "Eksternt skrivebord", "feature_desktop": "Eksternt skrivebord",
"feature_desktop_desc": "Åpne RDP-, VNC- og Telnet-økter i en fane, på samme måte som SSH." "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", "fontName": "Nazwa czcionki",
"fontSize": "Rozmiar czcionki", "fontSize": "Rozmiar czcionki",
"colorScheme": "Schemat kolorów", "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": { "onboarding": {
"feature_desktop": "Zdalny pulpit", "feature_desktop": "Zdalny pulpit",
"feature_desktop_desc": "Otwieraj sesje RDP, VNC i Telnet w karcie, tak samo jak SSH." "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", "fontName": "Nome da fonte",
"fontSize": "Tamanho da fonte", "fontSize": "Tamanho da fonte",
"colorScheme": "Esquema de cores", "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": { "onboarding": {
"feature_desktop": "Área de trabalho remota", "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." "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", "fontName": "Nome da Fonte",
"fontSize": "Tamanho da Fonte", "fontSize": "Tamanho da Fonte",
"colorScheme": "Esquema de Cores", "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": { "onboarding": {
"feature_desktop": "Área de trabalho remota", "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." "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", "fontName": "Nume font",
"fontSize": "Dimensiunea fontului", "fontSize": "Dimensiunea fontului",
"colorScheme": "Schemă de culori", "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": { "onboarding": {
"feature_desktop": "Desktop la distanță", "feature_desktop": "Desktop la distanță",
"feature_desktop_desc": "Deschideți sesiuni RDP, VNC și Telnet într-o filă, la fel ca în cazul SSH." "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": "Название шрифта", "fontName": "Название шрифта",
"fontSize": "Размер шрифта", "fontSize": "Размер шрифта",
"colorScheme": "Цветовая схема", "colorScheme": "Цветовая схема",
"backspaceKey": "Клавиша Backspace" "backspaceKey": "Клавиша Backspace",
} "colorDepth": "Глубина цвета",
"resizeMethod": "Метод изменения размера"
},
"remoteDesktop": "Удаленный рабочий стол",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Виртуальная сеть",
"unencryptedShell": "Незашифрованная оболочка"
}, },
"onboarding": { "onboarding": {
"feature_desktop": "Удаленный рабочий стол", "feature_desktop": "Удаленный рабочий стол",
"feature_desktop_desc": "Открывайте сеансы RDP, VNC и Telnet во вкладке, как и SSH." "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": "Назив фонта", "fontName": "Назив фонта",
"fontSize": "Величина фонта", "fontSize": "Величина фонта",
"colorScheme": "Шема боја", "colorScheme": "Шема боја",
"backspaceKey": "Тастер Backspace" "backspaceKey": "Тастер Backspace",
} "colorDepth": "Дубина боје",
"resizeMethod": "Метод промене величине"
},
"remoteDesktop": "Удаљена радна површина",
"tabRdp": "РДП",
"tabVnc": "VNC",
"tabTelnet": "Телнет",
"virtualNetwork": "Виртуелна мрежа",
"unencryptedShell": "Нешифрована љуска"
}, },
"onboarding": { "onboarding": {
"feature_desktop": "Удаљена радна површина", "feature_desktop": "Удаљена радна површина",
"feature_desktop_desc": "Отворите RDP, VNC и Telnet сесије у картици, исто као и SSH." "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", "fontName": "Typsnittsnamn",
"fontSize": "Fontstorlek", "fontSize": "Fontstorlek",
"colorScheme": "Färgschema", "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": { "onboarding": {
"feature_desktop": "Fjärrskrivbord", "feature_desktop": "Fjärrskrivbord",
"feature_desktop_desc": "Öppna RDP-, VNC- och Telnet-sessioner i en flik, samma som med SSH." "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": "ชื่อแบบอักษร", "fontName": "ชื่อแบบอักษร",
"fontSize": "ขนาดตัวอักษร", "fontSize": "ขนาดตัวอักษร",
"colorScheme": "โทนสี", "colorScheme": "โทนสี",
"backspaceKey": "ปุ่มลบ" "backspaceKey": "ปุ่มลบ",
} "colorDepth": "ความลึกของสี",
"resizeMethod": "วิธีการปรับขนาด"
},
"remoteDesktop": "รีโมทเดสก์ท็อป",
"tabRdp": "อาร์ดีพี",
"tabVnc": "วีเอ็นซี",
"tabTelnet": "เทลเน็ต",
"virtualNetwork": "เครือข่ายเสมือน",
"unencryptedShell": "เชลล์ที่ไม่ได้เข้ารหัส"
}, },
"onboarding": { "onboarding": {
"feature_desktop": "การเข้าถึงเดสก์ท็อประยะไกล", "feature_desktop": "การเข้าถึงเดสก์ท็อประยะไกล",
"feature_desktop_desc": "เปิดเซสชัน RDP, VNC และ Telnet ในแท็บเดียวกัน เหมือนกับการเปิด SSH" "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ı", "fontName": "Yazı Tipi Adı",
"fontSize": "Yazı Tipi Boyutu", "fontSize": "Yazı Tipi Boyutu",
"colorScheme": "Renk Şeması", "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": { "onboarding": {
"feature_desktop": "Uzaktan masaüstü", "feature_desktop": "Uzaktan masaüstü",
"feature_desktop_desc": "SSH'de olduğu gibi, RDP, VNC ve Telnet oturumlarını da bir sekmede açın." "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": "Назва шрифту", "fontName": "Назва шрифту",
"fontSize": "Розмір шрифту", "fontSize": "Розмір шрифту",
"colorScheme": "Колірна схема", "colorScheme": "Колірна схема",
"backspaceKey": "Клавіша Backspace" "backspaceKey": "Клавіша Backspace",
} "colorDepth": "Глибина кольору",
"resizeMethod": "Метод зміни розміру"
},
"remoteDesktop": "Віддалений робочий стіл",
"tabRdp": "ПРП",
"tabVnc": "VNC",
"tabTelnet": "Телнет",
"virtualNetwork": "Віртуальна мережа",
"unencryptedShell": "Незашифрована оболонка"
}, },
"onboarding": { "onboarding": {
"feature_desktop": "Віддалений робочий стіл", "feature_desktop": "Віддалений робочий стіл",
"feature_desktop_desc": "Відкривати сеанси RDP, VNC та Telnet у вкладці, так само, як SSH." "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ữ", "fontName": "Tên phông chữ",
"fontSize": "Cỡ chữ", "fontSize": "Cỡ chữ",
"colorScheme": "Bảng màu", "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": { "onboarding": {
"feature_desktop": "Máy tính từ xa", "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." "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": "字体名称", "fontName": "字体名称",
"fontSize": "字体大小", "fontSize": "字体大小",
"colorScheme": "配色方案", "colorScheme": "配色方案",
"backspaceKey": "退格键" "backspaceKey": "退格键",
} "colorDepth": "颜色深度",
"resizeMethod": "调整大小方法"
},
"remoteDesktop": "远程桌面",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "虚拟网络",
"unencryptedShell": "未加密 Shell"
}, },
"onboarding": { "onboarding": {
"feature_desktop": "远程桌面", "feature_desktop": "远程桌面",
"feature_desktop_desc": "像 SSH 一样,在标签页中直接打开 RDP、VNC 和 Telnet 会话。" "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": "字型名稱", "fontName": "字型名稱",
"fontSize": "字型大小", "fontSize": "字型大小",
"colorScheme": "色彩配置", "colorScheme": "色彩配置",
"backspaceKey": "退格鍵" "backspaceKey": "退格鍵",
} "colorDepth": "色彩深度",
"resizeMethod": "調整尺寸方法"
},
"remoteDesktop": "遠端桌面",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "虛擬網路",
"unencryptedShell": "未加密殼層"
}, },
"onboarding": { "onboarding": {
"feature_desktop": "遠端桌面", "feature_desktop": "遠端桌面",
"feature_desktop_desc": "像 SSH 一樣,在同一個標籤頁中開啟 RDP、VNC 和 Telnet 會話。" "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"
}
}
} }
} }
+376 -19
View File
@@ -16,15 +16,33 @@
}, },
"capabilities": [ "capabilities": [
"hosts:read", "hosts:read",
"credentials:read",
"credentials:use", "credentials:use",
"ssh:connect", "ssh:connect",
"network:serve", "network:serve",
"audit:read", "desktop:window",
"ui:surface" "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": [ "requires": [
{ {
"service": "recordings.writer", "service": "recordings.writer",
"versionRange": "^1.1.0",
"optional": true
},
{
"service": "tunnels.access",
"versionRange": "^1.0.0", "versionRange": "^1.0.0",
"optional": true "optional": true
} }
@@ -50,30 +68,369 @@
"openFrom": ["host-context-menu", "palette"] "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": [ "actionSlots": [
{ {
"id": "remote-desktop.toolbar", "id": "remote-desktop.toolbar",
"accepts": ["button"] "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", "locales": "locales",
"platforms": ["linux", "win32", "darwin"] "platforms": ["linux", "win32", "darwin"]
@@ -1,18 +1,20 @@
import GuacamoleLite from "guacamole-lite"; 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 fs from "fs";
import path from "path"; import path from "path";
import type { IncomingMessage } from "http";
const tokenService = GuacamoleTokenService.getInstance(); 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. */ /** The recordings.writer service, when the session-recording plugin provides it. */
export interface RecordingsWriter { export interface RecordingsWriter {
enabledFor: (hostId: number) => Promise<boolean>;
createFinished: (input: { createFinished: (input: {
hostId: number; hostId: number;
userId: string; userId: string;
@@ -25,147 +27,38 @@ export interface RecordingsWriter {
}) => Promise<{ id: number }>; }) => 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 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; guacamoleConnectionId?: string;
connectionSettings?: { connectionSettings?: {
connection?: { type?: string; join?: string; readOnly?: boolean }; connection?: { type?: string; join?: string; readOnly?: boolean };
recording?: GuacamoleRecordingMetadata; recording?: GuacamoleRecordingMetadata;
termixMeta?: { termixMeta?: TermixGuacMeta;
termixConnectId: string;
hostId: number;
ownerUserId: string;
protocol: string;
};
}; };
close?: () => void;
}; };
export interface GuacSessionInfo { const clientOptions = (
guacamoleConnectionId: string; tokens: GuacamoleTokenService,
hostId: number; log: RemoteDesktopLogger,
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 = {
crypt: { crypt: {
cypher: "AES-256-CBC", cypher: "AES-256-CBC",
key: tokenService.getEncryptionKey(), key: tokens.getEncryptionKey(),
}, },
log: { log: {
level: "ERRORS", level: "ERRORS",
stdLog: (...args: unknown[]) => { stdLog: (...args: unknown[]) => log.info(args.join(" ")),
guacLogger.info(args.join(" ")); errorLog: (...args: unknown[]) => log.error(args.join(" ")),
},
errorLog: (...args: unknown[]) => {
guacLogger.error(args.join(" "));
},
}, },
allowedUnencryptedConnectionSettings: { allowedUnencryptedConnectionSettings: {
rdp: ["width", "height", "dpi"], rdp: ["width", "height", "dpi"],
@@ -202,150 +95,224 @@ const clientOptions = {
"terminal-type": "xterm-256color", "terminal-type": "xterm-256color",
}, },
}, },
}; });
const _origConsoleLog = console.log; export interface GuacamoleServerDeps {
console.log = (...args: unknown[]) => { log: RemoteDesktopLogger;
const msg = args[0]; tokens: GuacamoleTokenService;
if (typeof msg === "string" && msg.startsWith("New client connection")) sessions: RemoteSessions;
return; guacd: () => GuacdOptions;
_origConsoleLog(...args); 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 { export interface GuacamoleServer {
const guacdOptions = readGuacdOptions(); handleUpgrade: (
const server = new GuacamoleLite( request: IncomingMessage,
websocketOptions, socket: Duplex,
guacdOptions, head: Buffer,
clientOptions, ) => 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", { * guacamole-lite in noServer mode: core owns the listening socket and hands
operation: "guac_connection_open", * upgrades from /plugin-ws/remote-desktop/display to its WebSocketServer.
type: clientConnection.connectionSettings?.connection?.type, */
}); export function createGuacamoleServer(
deps: GuacamoleServerDeps,
): GuacamoleServer {
const { log, sessions } = deps;
let server: GuacamoleLite | null = null;
const termixMeta = clientConnection.connectionSettings?.termixMeta; const persistRecording = async (connection: ClientConnection) => {
const guacamoleConnectionId = clientConnection.guacamoleConnectionId; const recording = connection.connectionSettings?.recording;
const isJoin = !!clientConnection.connectionSettings?.connection?.join; if (!recording) return;
if (!isJoin && termixMeta && guacamoleConnectionId) { const dir = recordingsDir();
const info: GuacSessionInfo = { const resolvedPath = path.resolve(dir, recording.path);
guacamoleConnectionId, if (!resolvedPath.startsWith(`${path.resolve(dir)}${path.sep}`)) return;
hostId: termixMeta.hostId,
ownerUserId: termixMeta.ownerUserId, // guacd may flush or rename the recording just after the socket closes.
protocol: termixMeta.protocol, for (
openedAt: Date.now(), let attempt = 0;
}; attempt < 10 && !fs.existsSync(resolvedPath);
guacSessionByConnectId.set(termixMeta.termixConnectId, info); attempt++
guacSessionByGuacamoleId.set(guacamoleConnectionId, info); ) {
await new Promise((resolve) => setTimeout(resolve, 100));
} }
}); if (!fs.existsSync(resolvedPath)) {
log.warn("Guacamole recording file was not found", {
server.on("close", (clientConnection: GuacamoleClientConnection) => { operation: "guac_recording_missing",
guacLogger.info("Guacamole connection closed", { hostId: recording.hostId,
operation: "guac_connection_close", path: resolvedPath,
type: clientConnection.connectionSettings?.connection?.type, guacdPath: recording.guacdPath ?? dir,
}); hint:
"guacd writes the recording to guacdPath, the backend reads it from path. " +
const isJoin = !!clientConnection.connectionSettings?.connection?.join; "When guacd runs in its own container these must be the same volume: set " +
const termixMeta = clientConnection.connectionSettings?.termixMeta; "GUACD_RECORDING_PATH to guacd's mount point and GUACD_RECORDING_BACKEND_PATH to this one.",
const guacamoleConnectionId = clientConnection.guacamoleConnectionId; });
if (!isJoin && termixMeta && guacamoleConnectionId) { return;
guacSessionByConnectId.delete(termixMeta.termixConnectId);
guacSessionByGuacamoleId.delete(guacamoleConnectionId);
} }
persistGuacamoleRecording(clientConnection).catch((error) => { const writer = await deps.asUser(recording.userId, async () =>
guacLogger.error("Failed to persist Guacamole recording", error, { deps.recordings(),
operation: "guac_recording_persist_error", );
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( guacamole.on("error", (connection: ClientConnection, error: Error) => {
"error", log.error("Guacamole connection error", {
(clientConnection: GuacamoleClientConnection, error: Error) => {
guacLogger.error("Guacamole connection error", error, {
operation: "guac_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,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));
}
+85 -40
View File
@@ -1,63 +1,108 @@
import type { PluginContext } from "@termix/plugin-sdk/backend"; 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 { import {
getGuacSessionInfo, createGuacamoleServer,
handleGuacamoleUpgrade,
restartGuacServer,
setPluginRecordings,
startGuacamoleService,
stopGuacamoleService,
tokenService,
type RecordingsWriter, type RecordingsWriter,
} from "./guacamole-server.js"; } 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) { export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh); const log = createLogger(ctx.log);
ctx.disposables.add(() => setPluginSsh(null)); const tokens = new GuacamoleTokenService(log);
const sessions = new RemoteSessions();
ctx.disposables.add(() => sessions.clear());
// Optional: without the session-recording plugin, a guacd recording is // The admin setting, unless GUACD_URL / GUACD_HOST / GUACD_PORT override it.
// still made on disk but never gets a row, so it never shows in the list. let guacdUrl = (await ctx.settings.get<string>("guacdUrl")) ?? "";
const recordings = ctx.services.get<RecordingsWriter>("recordings.writer"); const guacd = () => resolveGuacdOptions(guacdUrl || undefined);
setPluginRecordings("createFinished" in recordings ? recordings : null); const enabled = async () =>
ctx.disposables.add(() => setPluginRecordings(null)); (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. // guacamole-lite owns its own WebSocketServer, so it takes the raw upgrade.
// // Public because a display authenticates with the encrypted, single-use
// Public because a Guacamole display authenticates with the encrypted, // token minted by an authenticated route, not with a session JWT.
// 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.
ctx.ws.upgrade( ctx.ws.upgrade(
"/display", "/display",
(request, socket, head) => (request, socket, head) =>
handleGuacamoleUpgrade( server.handleUpgrade(
request as Parameters<typeof handleGuacamoleUpgrade>[0], request as Parameters<typeof server.handleUpgrade>[0],
socket as Parameters<typeof handleGuacamoleUpgrade>[1], socket as Parameters<typeof server.handleUpgrade>[1],
head as Buffer, head as Buffer,
), ),
{ public: true }, { public: true },
); );
// What collab, session-sharing and the admin guacd setting need from a for (const protocol of ["rdp", "vnc", "telnet"] as const) {
// running Guacamole server. Revoked automatically on deactivate, and ctx.services.provide(
// hosts/guacamole-sessions.ts degrades safely while it is gone. "sessions.live",
ctx.registry.provide("remote-desktop.sessions", { createLiveSessions(protocol, {
restart: restartGuacServer, sessions,
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) => tokens,
tokenService.createJoinToken(guacamoleConnectionId, readOnly), endSession: server.endSession,
getSessionInfo: getGuacSessionInfo, }),
}); { name: protocol },
);
}
ctx.log.info( ctx.registry.provide(
"Remote Desktop mounted at /plugin-api/remote-desktop and /plugin-ws/remote-desktop/display", "remote-desktop.hostImportNormalizer",
normalizeImportedHost,
); );
} }
export async function deactivate() { export async function deactivate() {}
await stopGuacamoleService();
}
@@ -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);
},
};
}
+30
View File
@@ -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 ? promptedDomain
: storedDomain; : 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.
}
}
}
-36
View File
@@ -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 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 { export interface GuacamoleConnectionSettings {
type?: "rdp" | "vnc" | "telnet"; type?: "rdp" | "vnc" | "telnet";
@@ -33,8 +37,10 @@ export interface GuacamoleConnectionSettings {
export interface TermixGuacMeta { export interface TermixGuacMeta {
termixConnectId: string; termixConnectId: string;
hostId: number; hostId: number;
hostName: string;
ownerUserId: string; ownerUserId: string;
protocol: "rdp" | "vnc" | "telnet"; protocol: "rdp" | "vnc" | "telnet";
tabInstanceId: string | null;
} }
export interface GuacamoleToken { export interface GuacamoleToken {
@@ -57,21 +63,19 @@ export interface GuacamoleRecordingMetadata {
const CIPHER = "aes-256-cbc"; const CIPHER = "aes-256-cbc";
const KEY_LENGTH = 32; 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 { export class GuacamoleTokenService {
private static instance: GuacamoleTokenService;
private encryptionKey: Buffer; private encryptionKey: Buffer;
private constructor() { constructor(private readonly log: TokenLogger = silentLogger) {
this.encryptionKey = this.initializeKey(); this.encryptionKey = this.initializeKey();
} }
static getInstance(): GuacamoleTokenService {
if (!GuacamoleTokenService.instance) {
GuacamoleTokenService.instance = new GuacamoleTokenService();
}
return GuacamoleTokenService.instance;
}
private initializeKey(): Buffer { private initializeKey(): Buffer {
const existingKey = process.env.GUACAMOLE_ENCRYPTION_KEY; const existingKey = process.env.GUACAMOLE_ENCRYPTION_KEY;
if (existingKey) { if (existingKey) {
@@ -91,12 +95,9 @@ export class GuacamoleTokenService {
.digest(); .digest();
} }
guacLogger.warn( this.log.warn("No persistent encryption key found, generating random key", {
"No persistent encryption key found, generating random key", operation: "guac_key_generation",
{ });
operation: "guac_key_generation",
},
);
return crypto.randomBytes(KEY_LENGTH); return crypto.randomBytes(KEY_LENGTH);
} }
@@ -134,8 +135,9 @@ export class GuacamoleTokenService {
return JSON.parse(decrypted) as GuacamoleToken; return JSON.parse(decrypted) as GuacamoleToken;
} catch (error) { } catch (error) {
guacLogger.error("Failed to decrypt guacamole token", error, { this.log.error("Failed to decrypt guacamole token", {
operation: "guac_token_decrypt_error", operation: "guac_token_decrypt_error",
error: error instanceof Error ? error.message : String(error),
}); });
return null; return null;
} }
@@ -1,5 +1,4 @@
import { watchGuacamoleConnectionId } from "./guacamole-session-id"; import { watchGuacamoleConnectionId } from "./guacamole-session-id";
import { getErrorMessage } from "@/lib/error-message.js";
import React, { import React, {
useState, useState,
useEffect, useEffect,
@@ -14,40 +13,42 @@ import {
type GuacamoleDisplayHandle, type GuacamoleDisplayHandle,
type GuacamoleTouchMode, type GuacamoleTouchMode,
} from "./GuacamoleDisplay.tsx"; } from "./GuacamoleDisplay.tsx";
import { getSSHHosts, logActivity, isElectron } from "@/main-axios";
import { getGuacamoleTokenFromHost, getGuacdStatus } from "./guacamole-api"; import { getGuacamoleTokenFromHost, getGuacdStatus } from "./guacamole-api";
import { readConfiguredDimension } from "./guacamole-display-size.ts"; import { readConfiguredDimension } from "./guacamole-display-size.ts";
import { getGuacamoleToken, parseGuacamoleConfig } from "./guacamole-api"; import { getGuacamoleToken } from "./guacamole-api";
import {
resolveConnectionOrigin,
type ConnectionOrigin,
} from "@/lib/connection-origin.ts";
import { import {
useHost,
useTranslation, useTranslation,
useConnectionRetry, useConnectionRetry,
} from "@termix/plugin-sdk/frontend"; } 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 { import {
Button,
ConnectionLogProvider,
ConnectionScreen,
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/dialog.tsx"; Input,
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx"; PasswordInput,
import { isElectron,
ConnectionLogProvider, logActivity,
resolveConnectionOrigin,
useConnectionLog, useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx"; type ConnectionOrigin,
import type { SSHHost } from "@/types"; } from "@termix/plugin-sdk/ui";
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext"; import type { GuacamoleConfig } from "./guacamole-config";
import { resolveConnectionDefaults } from "@/lib/connection-defaults"; 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"; import { needsRdpCredentialPrompt } from "./rdp-credential-prompt";
interface GuacamoleAppProps { interface GuacamoleAppProps {
@@ -59,10 +60,48 @@ interface GuacamoleAppProps {
quickConnectHost?: GuacamoleQuickHost; 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; 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 { export interface GuacamoleAppHandle {
disconnect: () => void; disconnect: () => void;
@@ -77,50 +116,20 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
ref, ref,
) { ) {
const { t } = useTranslation(); const { t } = useTranslation();
const defaults = useConnectionDefaults(); const savedHost = useHost(quickConnectHost ? undefined : hostId);
const [hostConfig, setHostConfig] = useState<GuacamoleQuickHost | null>( const hostConfig: GuacamoleHostConfig | null = quickConnectHost
null, ? quickConnectHost
); : savedHost
const [loading, setLoading] = useState(true); ? 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(() => { useEffect(() => {
if (!defaults.ready) return; const timer = setTimeout(() => setWaited(true), 3000);
if (quickConnectHost) { return () => clearTimeout(timer);
const connectionType = protocol ?? quickConnectHost.connectionType; }, []);
setHostConfig({ const loading = !hostConfig && !!hostId && !waited;
...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]);
if (loading) { if (loading) {
return ( return (
@@ -159,23 +168,7 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
interface GuacamoleAppInnerProps { interface GuacamoleAppInnerProps {
hostId: number; hostId: number;
hostConfig: Pick< hostConfig: GuacamoleHostConfig;
SSHHost,
| "connectionType"
| "domain"
| "guacamoleConfig"
| "rdpAuthType"
| "authOverrides"
| "syncId"
| "connectionOrigin"
| "ip"
| "rdpPort"
| "vncPort"
| "rdpUser"
| "rdpPassword"
| "vncUser"
| "vncPassword"
> & { enableTerminalToolbar?: boolean };
hostName: string; hostName: string;
tabId?: string; tabId?: string;
protocol?: "rdp" | "vnc" | "telnet"; protocol?: "rdp" | "vnc" | "telnet";
@@ -223,7 +216,7 @@ const GuacamoleAppInner = React.forwardRef<
const [toolbarHidden, setToolbarHidden] = useState(false); const [toolbarHidden, setToolbarHidden] = useState(false);
const [pendingUploads, setPendingUploads] = useState<File[]>([]); const [pendingUploads, setPendingUploads] = useState<File[]>([]);
const guacConfig = parseGuacamoleConfig(hostConfig.guacamoleConfig); const guacConfig = hostConfig.guacamoleConfig ?? {};
const allowUpload = guacConfig.disableUpload !== true; const allowUpload = guacConfig.disableUpload !== true;
const allowDownload = guacConfig.disableDownload !== true; const allowDownload = guacConfig.disableDownload !== true;
@@ -311,10 +304,10 @@ const GuacamoleAppInner = React.forwardRef<
let resolvedOrigin: ConnectionOrigin = "local"; let resolvedOrigin: ConnectionOrigin = "local";
if (isElectron()) { if (isElectron()) {
resolvedOrigin = await resolveConnectionOrigin({ resolvedOrigin = await resolveConnectionOrigin(
connectionType: resolvedProtocolForConnect, { connectionOrigin: hostConfig.connectionOrigin },
connectionOrigin: hostConfig.connectionOrigin, { defaultRemote: true },
}); );
if (resolvedOrigin === "remote") { if (resolvedOrigin === "remote") {
const remoteConfig = (await window.electronAPI?.invoke?.( const remoteConfig = (await window.electronAPI?.invoke?.(
"get-remote-sync-config", "get-remote-sync-config",
@@ -331,6 +324,9 @@ const GuacamoleAppInner = React.forwardRef<
message: t("remoteDesktop.checkingGuacd"), message: t("remoteDesktop.checkingGuacd"),
}); });
const status = await getGuacdStatus(resolvedOrigin); const status = await getGuacdStatus(resolvedOrigin);
if (status.enabled === false) {
throw new Error(t("remoteDesktop.disabled"));
}
if (status.guacd.status !== "connected") { if (status.guacd.status !== "connected") {
throw new Error(t("remoteDesktop.guacdUnavailable")); throw new Error(t("remoteDesktop.guacdUnavailable"));
} }
@@ -351,21 +347,12 @@ const GuacamoleAppInner = React.forwardRef<
{ {
protocol: resolvedProtocolForConnect, protocol: resolvedProtocolForConnect,
hostname: hostConfig.ip, hostname: hostConfig.ip,
port: port: hostConfig.port,
resolvedProtocolForConnect === "vnc" username: hostConfig.username,
? hostConfig.vncPort password: hostConfig.password,
: hostConfig.rdpPort,
username:
resolvedProtocolForConnect === "vnc"
? hostConfig.vncUser
: hostConfig.rdpUser,
password:
resolvedProtocolForConnect === "vnc"
? hostConfig.vncPassword
: hostConfig.rdpPassword,
domain: hostConfig.domain, domain: hostConfig.domain,
ignoreCert: true, ignoreCert: true,
guacamoleConfig: parseGuacamoleConfig(hostConfig.guacamoleConfig), guacamoleConfig: hostConfig.guacamoleConfig,
}, },
resolvedOrigin, resolvedOrigin,
) )
@@ -375,6 +362,7 @@ const GuacamoleAppInner = React.forwardRef<
protocol, protocol,
promptedCredentials ?? undefined, promptedCredentials ?? undefined,
hostConfig.syncId, hostConfig.syncId,
tabId,
); );
if (result) { if (result) {
setSessionLookup( setSessionLookup(
@@ -397,6 +385,7 @@ const GuacamoleAppInner = React.forwardRef<
promptedCredentials, promptedCredentials,
resolvedProtocolForConnect, resolvedProtocolForConnect,
hostConfig, hostConfig,
tabId,
addLog, addLog,
t, t,
]); ]);
@@ -407,10 +396,7 @@ const GuacamoleAppInner = React.forwardRef<
await fetchToken(); await fetchToken();
tokenRetryRef.current.markConnected(); tokenRetryRef.current.markConnected();
} catch (err: unknown) { } catch (err: unknown) {
const message = getErrorMessage( const message = errorMessage(err, t("remoteDesktop.failedToConnect"));
err,
t("remoteDesktop.failedToConnect"),
);
setError(message || t("remoteDesktop.failedToConnect")); setError(message || t("remoteDesktop.failedToConnect"));
addLog({ type: "error", stage: "error", message }); addLog({ type: "error", stage: "error", message });
tokenRetryRef.current.markFailed(); tokenRetryRef.current.markFailed();
@@ -632,7 +618,7 @@ const GuacamoleAppInner = React.forwardRef<
onClose={() => setFileBrowserOpen(false)} onClose={() => setFileBrowserOpen(false)}
/> />
)} )}
{hostConfig.enableTerminalToolbar !== false && !toolbarHidden && ( {hostConfig.showToolbar !== false && !toolbarHidden && (
<GuacamoleToolbar <GuacamoleToolbar
displayRef={displayRef} displayRef={displayRef}
protocol={resolvedProtocol} protocol={resolvedProtocol}
@@ -1,4 +1,3 @@
import { getErrorMessage } from "@/lib/error-message.js";
import type React from "react"; import type React from "react";
import { import {
useEffect, useEffect,
@@ -11,15 +10,18 @@ import {
import Guacamole from "guacamole-common-js"; import Guacamole from "guacamole-common-js";
import { Upload } from "lucide-react"; import { Upload } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import { isElectron } from "@/main-axios";
import { getGuacamoleToken } from "./guacamole-api"; import { getGuacamoleToken } from "./guacamole-api";
import { getBasePath } from "@/lib/base-path.ts";
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts"; import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
import { import {
resolveConnectionOrigin,
buildOriginWsUrl, buildOriginWsUrl,
getBasePath,
guacStateToStage,
isElectron,
resolveConnectionOrigin,
type ConnectionOrigin, 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 { isPasteShortcut, pasteTextToRemote } from "./guacamole-clipboard.ts";
import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts"; import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts";
import { bindPointerInput } from "./guacamole-pointer.ts"; import { bindPointerInput } from "./guacamole-pointer.ts";
@@ -31,8 +33,6 @@ import {
uploadFileToClient, uploadFileToClient,
type GuacamoleFileStreamClient, type GuacamoleFileStreamClient,
} from "./guacamole-filesystem.ts"; } 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"; import { clampGuacamoleZoom, stepGuacamoleZoom } from "./guacamole-zoom.ts";
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet"; 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 // Resolved before the token is minted, not just before the socket is
// opened: the token has to come from whichever backend will serve the // opened: the token has to come from whichever backend will serve the
// session, so both steps have to agree on the origin. // session, so both steps have to agree on the origin.
const origin = await resolveConnectionOrigin({ const origin = await resolveConnectionOrigin(
connectionType: connectionProtocol, { connectionOrigin: connectionConfig.connectionOrigin },
connectionOrigin: connectionConfig.connectionOrigin, { defaultRemote: true },
}); );
if (connectionConfig.token) { if (connectionConfig.token) {
token = connectionConfig.token; token = connectionConfig.token;
@@ -317,8 +317,8 @@ export const GuacamoleDisplay = forwardRef<
if (displaySize.dpi) params.set("dpi", String(displaySize.dpi)); if (displaySize.dpi) params.set("dpi", String(displaySize.dpi));
return { url: wsBase, query: params.toString() }; return { url: wsBase, query: params.toString() };
} catch (error) { } catch (error) {
const errorMessage = getErrorMessage(error); const message = errorMessage(error);
onError?.(errorMessage); onError?.(message);
return null; return null;
} }
}, },
@@ -698,7 +698,7 @@ export const GuacamoleDisplay = forwardRef<
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
setIsReady(false); setIsReady(false);
setHasError(true); setHasError(true);
onError?.(getErrorMessage(error, t("remoteDesktop.connectionError"))); onError?.(errorMessage(error, t("remoteDesktop.connectionError")));
} }
}, [ }, [
getWebSocketConnection, 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 { useCallback, useEffect, useRef, useState } from "react";
import Guacamole from "guacamole-common-js"; import Guacamole from "guacamole-common-js";
import { useTranslation } from "@termix/plugin-sdk/frontend"; import { useTranslation } from "@termix/plugin-sdk/frontend";
@@ -12,7 +12,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/button.tsx"; import { Button } from "@termix/plugin-sdk/ui";
import { import {
downloadFile, downloadFile,
listDirectory, listDirectory,
@@ -56,7 +56,7 @@ export function GuacamoleFileBrowser({
setEntries(await listDirectory(filesystem, target)); setEntries(await listDirectory(filesystem, target));
} catch (err) { } catch (err) {
setEntries([]); setEntries([]);
setError(getErrorMessage(err, t("remoteDesktop.files.listFailed"))); setError(errorMessage(err, t("remoteDesktop.files.listFailed")));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -26,14 +26,14 @@ import {
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/tooltip.tsx"; } from "@termix/plugin-sdk/ui";
import type { import type {
GuacamoleDisplayHandle, GuacamoleDisplayHandle,
GuacamoleTouchMode, GuacamoleTouchMode,
} from "./GuacamoleDisplay.tsx"; } from "./GuacamoleDisplay.tsx";
import { useTranslation } from "@termix/plugin-sdk/frontend"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import { ActionSlot } from "@termix/plugin-sdk/ui"; import { ActionSlot } from "@termix/plugin-sdk/ui";
import { cn } from "@/lib/utils"; import { cn } from "@termix/plugin-sdk/ui";
import { import {
detectRuntimeMetaKeyFamily, detectRuntimeMetaKeyFamily,
metaKeyLabels, metaKeyLabels,
@@ -1,8 +1,12 @@
import { useTranslation } from "@termix/plugin-sdk/frontend"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Input } from "@/components/input"; import {
import { PasswordInput } from "@/components/password-input"; FakeSwitch,
import { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card"; Input,
import type { Host } from "@/types/ui-types"; PasswordInput,
SectionCard,
Select2,
SettingRow,
} from "@termix/plugin-sdk/ui";
import { import {
Activity, Activity,
Copy, Copy,
@@ -16,13 +20,10 @@ import {
Zap, Zap,
Cpu, Cpu,
} from "lucide-react"; } from "lucide-react";
import type { HostEditorForm } from "@/sidebar/HostEditorData"; import type { RemoteDesktopForm, RemoteFormSetField } from "./remote-form";
import { Select2 } from "@/components/select2";
type HostEditorSetField = <K extends keyof HostEditorForm>( type HostEditorForm = RemoteDesktopForm;
key: K, type HostEditorSetField = RemoteFormSetField;
value: HostEditorForm[K],
) => void;
type GuacFieldSetter = (key: string, value: unknown) => void; type GuacFieldSetter = (key: string, value: unknown) => void;
@@ -49,7 +50,7 @@ export function HostEditorRdpTab({
form: HostEditorForm; form: HostEditorForm;
setField: HostEditorSetField; setField: HostEditorSetField;
setGuacField: GuacFieldSetter; setGuacField: GuacFieldSetter;
host?: Host | null; host?: { macAddress?: string | null } | null;
credentials?: { id: string; name: string; username: string }[]; credentials?: { id: string; name: string; username: string }[];
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -984,7 +985,7 @@ export function HostEditorVncTab({
form: HostEditorForm; form: HostEditorForm;
setField: HostEditorSetField; setField: HostEditorSetField;
setGuacField: GuacFieldSetter; setGuacField: GuacFieldSetter;
host?: Host | null; host?: { macAddress?: string | null } | null;
credentials?: { id: string; name: string; username: string }[]; credentials?: { id: string; name: string; username: string }[];
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -1523,7 +1524,6 @@ export function HostEditorTelnetTab({
form: HostEditorForm; form: HostEditorForm;
setField: HostEditorSetField; setField: HostEditorSetField;
setGuacField: GuacFieldSetter; setGuacField: GuacFieldSetter;
host?: Host | null;
credentials?: { id: string; name: string; username: string }[]; credentials?: { id: string; name: string; username: string }[];
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -1,13 +1,18 @@
import type { PluginApiClient, TermixApp } from "@termix/plugin-sdk/frontend";
import { import {
authApi,
getRemoteGuacamoleApi,
handleApiError,
isElectron, isElectron,
} from "@/main-axios"; resolveRemoteHostId,
import type { AxiosInstance } from "axios"; type ConnectionOrigin,
import type { GuacamoleConfig } from "@/types/guacamole-config"; } from "@termix/plugin-sdk/ui";
import { resolveRemoteHostId } from "@/lib/remote-server-api"; import type { GuacamoleConfig } from "./guacamole-config";
import type { ConnectionOrigin } from "@/lib/connection-origin"; 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 * 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 * silently sent a missed call site to the remote server, which fails with a
* bare network error on a desktop that has none configured. * bare network error on a desktop that has none configured.
*/ */
function guacamoleApi(origin: ConnectionOrigin): AxiosInstance { function guacamoleApi(origin: ConnectionOrigin): PluginApiClient {
if (!isElectron()) return authApi; if (!app) throw new Error("Remote Desktop is not active");
return origin === "local" ? authApi : getRemoteGuacamoleApi(); return app.apiFor(origin);
}
function apiError(error: unknown, action: string): Error {
return new Error(errorMessage(error, `Failed to ${action}`));
} }
export interface GuacamoleTokenRequest { export interface GuacamoleTokenRequest {
@@ -51,18 +60,6 @@ type GuacamoleConfigSource = {
guacamoleConfig?: string | Record<string, unknown> | null; 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( export function getGuacamoleDpi(
source?: GuacamoleConfigSource, source?: GuacamoleConfigSource,
): number | undefined { ): number | undefined {
@@ -174,8 +171,8 @@ export async function getGuacamoleToken(
try { try {
const guacParams = toGuacamoleParams(request.guacamoleConfig); const guacParams = toGuacamoleParams(request.guacamoleConfig);
const response = await guacamoleApi(origin).post( const response = await guacamoleApi(origin).post<GuacamoleTokenResponse>(
"/plugin-api/remote-desktop/token", "/token",
{ {
type: request.protocol, type: request.protocol,
hostname: request.hostname, hostname: request.hostname,
@@ -190,7 +187,7 @@ export async function getGuacamoleToken(
); );
return response.data; return response.data;
} catch (error) { } 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; domain?: string;
}, },
syncId?: string | null, syncId?: string | null,
tabInstanceId?: string,
): Promise<GuacamoleTokenResponse> { ): Promise<GuacamoleTokenResponse> {
try { try {
// A locally-originated session is served by the embedded backend, which // 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"); throw new Error("The synced host does not exist on the remote server");
} }
const targetHostId = remoteHostId ?? hostId; const targetHostId = remoteHostId ?? hostId;
const response = await guacamoleApi(origin).post( const response = await guacamoleApi(origin).post<GuacamoleTokenResponse>(
`/plugin-api/remote-desktop/connect-host/${targetHostId}`, `/connect-host/${targetHostId}`,
{ {
...(protocol ? { protocol } : {}), ...(protocol ? { protocol } : {}),
...(tabInstanceId ? { tabInstanceId } : {}),
...(promptedCredentials?.username ...(promptedCredentials?.username
? { promptedUsername: promptedCredentials.username } ? { promptedUsername: promptedCredentials.username }
: {}), : {}),
@@ -233,15 +232,22 @@ export async function getGuacamoleTokenFromHost(
); );
return response.data; return response.data;
} catch (error) { } 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 }; 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; return response.data;
} }
@@ -251,9 +257,34 @@ export async function getGuacamoleConnectionId(
origin: ConnectionOrigin, origin: ConnectionOrigin,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<string | null> { ): Promise<string | null> {
const response = await guacamoleApi(origin).get( const response = await guacamoleApi(origin).get<{
`/plugin-api/remote-desktop/connection/${encodeURIComponent(connectId)}`, guacamoleConnectionId?: string | null;
{ signal }, }>(`/connection/${encodeURIComponent(connectId)}`, { signal });
);
return response.data.guacamoleConnectionId ?? null; 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;
}
@@ -1,5 +1,5 @@
import { getGuacamoleConnectionId } from "./guacamole-api"; import { getGuacamoleConnectionId } from "./guacamole-api";
import type { ConnectionOrigin } from "@/lib/connection-origin"; import type { ConnectionOrigin } from "@termix/plugin-sdk/ui";
export function watchGuacamoleConnectionId( export function watchGuacamoleConnectionId(
connectId: string, 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;
}
+172 -126
View File
@@ -1,23 +1,21 @@
import type { ComponentType, Ref } from "react"; import type { ComponentType, Ref } from "react";
import i18next from "i18next";
import { toast } from "sonner";
import { import {
Info,
MessagesSquare, MessagesSquare,
Monitor, Monitor,
MonitorUp, MonitorUp,
MousePointerClick, MousePointerClick,
Settings,
} from "lucide-react"; } from "lucide-react";
import { import {
useTranslation, useTranslation,
type HostEditorSectionProps, type HostEditorSectionProps,
type PluginHostRecord,
type StandaloneViewProps, type StandaloneViewProps,
type TabProps, type TabProps,
type TermixApp, type TermixApp,
} from "@termix/plugin-sdk/frontend"; } from "@termix/plugin-sdk/frontend";
import { FakeSwitch, SectionCard, SettingRow } from "@termix/plugin-sdk/ui"; import { SectionCard, isElectron } from "@termix/plugin-sdk/ui";
import type { Host } from "@/types/ui-types"; import { toast } from "sonner";
import { isQuickConnectHost } from "@/sidebar/quick-connect-host";
import GuacamoleApp, { type GuacamoleAppHandle } from "./GuacamoleApp"; import GuacamoleApp, { type GuacamoleAppHandle } from "./GuacamoleApp";
import { GuacamoleDisplay } from "./GuacamoleDisplay"; import { GuacamoleDisplay } from "./GuacamoleDisplay";
import { import {
@@ -25,48 +23,70 @@ import {
HostEditorTelnetTab, HostEditorTelnetTab,
HostEditorVncTab, HostEditorVncTab,
} from "./HostEditorGuacamoleTabs"; } from "./HostEditorGuacamoleTabs";
import { getGuacamoleTokenFromHost } from "./guacamole-api"; import {
getGuacamoleTokenFromHost,
getGuacdStatus,
nativeRdpAvailable,
openNativeRdp,
setRemoteDesktopApp,
} from "./guacamole-api";
import { quickConnectGuacHost } from "./quick-connect-guac-host"; import { quickConnectGuacHost } from "./quick-connect-guac-host";
import { REMOTE_DESKTOP_TOOLBAR_SLOT } from "./GuacamoleToolbar.tsx"; import { REMOTE_DESKTOP_TOOLBAR_SLOT } from "./GuacamoleToolbar.tsx";
import {
type Protocol = "rdp" | "vnc" | "telnet"; DEFAULT_PORT,
ENABLE_KEY,
PORT_KEY,
hostRemoteOptions,
isQuickConnectHost,
protocolEnabled,
type Protocol,
type RemoteHostLogin,
} from "./host-remote";
import { remoteDesktopForm } from "./remote-form";
const PROTOCOLS: { const PROTOCOLS: {
id: Protocol; id: Protocol;
titleKey: string; titleKey: string;
descriptionKey: string;
paletteKey: string;
icon: ComponentType<{ className?: string }>; icon: ComponentType<{ className?: string }>;
enableKey: "enableRdp" | "enableVnc" | "enableTelnet";
priority: number; priority: number;
order: number; order: number;
quickConnect?: { showDomain?: boolean };
}[] = [ }[] = [
{ {
id: "rdp", id: "rdp",
titleKey: "hosts.tabRdp", titleKey: "hosts.tabRdp",
descriptionKey: "hosts.remoteDesktop",
paletteKey: "palette.connectRdp",
icon: Monitor, icon: Monitor,
enableKey: "enableRdp",
priority: 50, priority: 50,
order: 100, order: 100,
quickConnect: { showDomain: true },
}, },
{ {
id: "vnc", id: "vnc",
titleKey: "hosts.tabVnc", titleKey: "hosts.tabVnc",
descriptionKey: "hosts.virtualNetwork",
paletteKey: "palette.connectVnc",
icon: MousePointerClick, icon: MousePointerClick,
enableKey: "enableVnc",
priority: 40, priority: 40,
order: 110, order: 110,
quickConnect: {},
}, },
{ {
id: "telnet", id: "telnet",
titleKey: "hosts.tabTelnet", titleKey: "hosts.tabTelnet",
descriptionKey: "hosts.unencryptedShell",
paletteKey: "palette.connectTelnet",
icon: MessagesSquare, icon: MessagesSquare,
enableKey: "enableTelnet",
priority: 30, priority: 30,
order: 120, order: 120,
}, },
]; ];
function RemoteDesktopTab({ tab, host, isVisible, handleRef }: TabProps) { function RemoteDesktopTab({ tab, host, isVisible, handleRef }: TabProps) {
const shellHost = host as unknown as Host; const record = host as unknown as RemoteHostLogin | undefined;
return ( return (
<GuacamoleApp <GuacamoleApp
ref={handleRef as Ref<GuacamoleAppHandle>} ref={handleRef as Ref<GuacamoleAppHandle>}
@@ -75,8 +95,8 @@ function RemoteDesktopTab({ tab, host, isVisible, handleRef }: TabProps) {
protocol={tab.type as Protocol} protocol={tab.type as Protocol}
isVisible={isVisible} isVisible={isVisible}
quickConnectHost={ quickConnectHost={
isQuickConnectHost(shellHost) record && isQuickConnectHost(record)
? quickConnectGuacHost(shellHost) ? quickConnectGuacHost(record)
: undefined : undefined
} }
/> />
@@ -111,79 +131,65 @@ function RemoteDisplay({
); );
} }
/** Applies one remote desktop field and stops inheriting user defaults. */ function SectionNotes({ protocol }: { protocol: Protocol }) {
function useGuacFieldSetter( const { t } = useTranslation();
updateForm: HostEditorSectionProps["updateForm"], return (
): (key: string, value: unknown) => void { <SectionCard
return (key, value) => title={t("hosts.remoteDesktopNotes")}
updateForm((current) => ({ icon={<Info className="size-3.5" />}
inheritRemoteDesktopDefaults: false, >
guacamoleConfig: { <div className="flex flex-col gap-2 py-3 text-xs text-muted-foreground">
...((current.guacamoleConfig as Record<string, unknown>) ?? {}), {protocol === "rdp" && <p>{t("hosts.userDefaultsNote")}</p>}
[key]: value, {isElectron() && <p>{t("hosts.connectionOriginNote")}</p>}
}, </div>
})); </SectionCard>
);
} }
function RdpSection(props: HostEditorSectionProps) { function RdpSection(props: HostEditorSectionProps) {
const { t } = useTranslation(); const { form, setField, setGuacField } = remoteDesktopForm(props);
const setGuacField = useGuacFieldSetter(props.updateForm);
return ( 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 <HostEditorRdpTab
form={props.form} form={form}
setField={props.setField as never} setField={setField}
setGuacField={setGuacField as never} setGuacField={setGuacField}
host={props.host as unknown as Host} host={props.host as { macAddress?: string | null } | undefined}
credentials={props.credentials as never} credentials={props.credentials as never}
/> />
<SectionNotes protocol="rdp" />
</> </>
); );
} }
function VncSection(props: HostEditorSectionProps) { function VncSection(props: HostEditorSectionProps) {
const setGuacField = useGuacFieldSetter(props.updateForm); const { form, setField, setGuacField } = remoteDesktopForm(props);
return ( return (
<HostEditorVncTab <>
form={props.form} <HostEditorVncTab
setField={props.setField as never} form={form}
setGuacField={setGuacField as never} setField={setField}
host={props.host as unknown as Host} setGuacField={setGuacField}
credentials={props.credentials as never} host={props.host as { macAddress?: string | null } | undefined}
/> credentials={props.credentials as never}
/>
<SectionNotes protocol="vnc" />
</>
); );
} }
function TelnetSection(props: HostEditorSectionProps) { function TelnetSection(props: HostEditorSectionProps) {
const setGuacField = useGuacFieldSetter(props.updateForm); const { form, setField, setGuacField } = remoteDesktopForm(props);
return ( return (
<HostEditorTelnetTab <>
form={props.form} <HostEditorTelnetTab
setField={props.setField as never} form={form}
setGuacField={setGuacField as never} setField={setField}
credentials={props.credentials as never} setGuacField={setGuacField}
/> credentials={props.credentials as never}
/>
<SectionNotes protocol="telnet" />
</>
); );
} }
@@ -193,23 +199,96 @@ const SECTIONS: Record<Protocol, ComponentType<HostEditorSectionProps>> = {
telnet: TelnetSection, telnet: TelnetSection,
}; };
async function openNativeRdp(host: Record<string, unknown>): Promise<void> { function registerNativeRdp(app: TermixApp): void {
const t = (key: string) => i18next.t(`remote-desktop:${key}`); let disposed = false;
try { app.onDispose(() => {
const result = await window.electronAPI.openNativeRdp({ disposed = true;
host: String(host.ip), });
port: (host.rdpPort as number | undefined) ?? 3389, void nativeRdpAvailable().then((available) => {
username: host.rdpUser as string | undefined, if (disposed || !available) return;
domain: host.domain as string | undefined, 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 { export function activate(app: TermixApp): void {
setRemoteDesktopApp(app);
app.onDispose(() => setRemoteDesktopApp(null));
// Guest pages only draw shared streams. // Guest pages only draw shared streams.
app.registerSlotContribution("session.remoteDisplay", { app.registerSlotContribution("session.remoteDisplay", {
actionId: "remote-desktop.display", actionId: "remote-desktop.display",
@@ -253,57 +332,24 @@ export function activate(app: TermixApp): void {
noHostMessageKey: "remoteDesktop.noHostSelected", noHostMessageKey: "remoteDesktop.noHostSelected",
persistent: true, persistent: true,
session: true, session: true,
restore: (host) => !!host[protocol.enableKey], restore: (host) => protocolEnabled(host, protocol.id),
activityTypes: [protocol.id], activityTypes: [protocol.id],
standalone: RemoteDesktopStandalone, standalone: RemoteDesktopStandalone,
preload: () => import("./GuacamoleApp"), 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. // An admin can turn Remote Desktop off; its ways in go with it.
if (window.electronAPI?.isElectron) { let disposed = false;
let disposed = false; app.onDispose(() => {
app.onDispose(() => { disposed = true;
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", { app.registerSlotContribution("onboarding.features", {
actionId: "remote-desktop.feature", actionId: "remote-desktop.feature",
@@ -1,19 +1,24 @@
import type { Host } from "@/types/ui-types";
import type { GuacamoleQuickHost } from "./GuacamoleApp"; 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. */ /** 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 { return {
name: host.name, name: host.name ?? undefined,
ip: host.ip, ip: host.ip ?? "",
connectionType: host.enableVnc ? "vnc" : "rdp", connectionType,
domain: host.domain, domain: host.domain,
rdpPort: host.rdpPort, port: connectionType === "vnc" ? options.vncPort : options.rdpPort,
vncPort: host.vncPort,
rdpAuthType: host.rdpAuthType, rdpAuthType: host.rdpAuthType,
rdpUser: host.rdpUser, username: connectionType === "vnc" ? host.vncUser : host.rdpUser,
rdpPassword: host.rdpPassword, password: connectionType === "vnc" ? host.vncPassword : host.rdpPassword,
vncUser: host.vncUser,
vncPassword: host.vncPassword,
}; };
} }
@@ -1,4 +1,4 @@
import type { HostAuthOverrides } from "@/types/auth-protocols"; import type { RemoteHostLogin } from "./host-remote";
export function needsRdpCredentialPrompt({ export function needsRdpCredentialPrompt({
protocol, protocol,
@@ -7,7 +7,7 @@ export function needsRdpCredentialPrompt({
}: { }: {
protocol: "rdp" | "vnc" | "telnet"; protocol: "rdp" | "vnc" | "telnet";
rdpAuthType?: string; rdpAuthType?: string;
authOverrides?: HostAuthOverrides; authOverrides?: RemoteHostLogin["authOverrides"];
}): boolean { }): boolean {
return ( return (
protocol === "rdp" && 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 { describe, expect, it } from "vitest";
import { import {
buildRdpSettings, buildRdpSettings,
resolveRdpAuthTypeForConnect,
resolveRdpDomain, resolveRdpDomain,
} from "../../src/backend/rdp-settings.js"; } from "../../src/backend/rdp-settings.js";
@@ -53,32 +52,3 @@ describe("buildRdpSettings", () => {
expect(resolveRdpDomain("none", undefined, "SAVED")).toBe("SAVED"); 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", () => ({ import { GuacamoleTokenService } from "../../src/backend/token-service.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");
describe("GuacamoleTokenService", () => { describe("GuacamoleTokenService", () => {
const tokenService = GuacamoleTokenService.getInstance(); const tokenService = new GuacamoleTokenService();
it("disables RDP pre-authentication when no credentials are configured", () => { it("disables RDP pre-authentication when no credentials are configured", () => {
const token = tokenService.createRdpToken("windows.example.test", "", ""); 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 { import {
renderWithApp, renderWithApp,
type RenderedPluginApp, type RenderedPluginApp,
@@ -45,9 +45,39 @@ describe(`${manifest.id} activate`, () => {
expect(app.registered.panels()).toEqual([]); expect(app.registered.panels()).toEqual([]);
expect(app.registered.railItems()).toEqual([]); expect(app.registered.railItems()).toEqual([]);
expect(app.registered.hostActions()).toEqual([]); expect(app.registered.hostActions()).toEqual([]);
expect(app.registered.hostProtocols()).toEqual([]);
expect(app.registered.hostEditorSections()).toEqual([]); expect(app.registered.hostEditorSections()).toEqual([]);
expect(app.registered.dashboardCards()).toEqual([]); expect(app.registered.dashboardCards()).toEqual([]);
expect(app.registered.settingsComponents()).toEqual([]); expect(app.registered.settingsComponents()).toEqual([]);
expect(app.registered.actions()).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 isElectronMock = vi.hoisted(() => vi.fn(() => false));
const resolveRemoteHostIdMock = vi.hoisted(() => vi.fn()); const resolveRemoteHostIdMock = vi.hoisted(() => vi.fn());
vi.mock("@/main-axios", () => ({ vi.mock("@termix/plugin-sdk/ui", () => ({
authApi: authApiMock,
getRemoteGuacamoleApi: () => remoteApiMock,
isElectron: isElectronMock, isElectron: isElectronMock,
handleApiError: (error: unknown) => error,
}));
vi.mock("@/lib/remote-server-api", () => ({
resolveRemoteHostId: resolveRemoteHostIdMock, resolveRemoteHostId: resolveRemoteHostIdMock,
})); }));
import { import {
setRemoteDesktopApp,
getGuacdStatus, getGuacdStatus,
getGuacamoleConnectionId, getGuacamoleConnectionId,
getGuacamoleTokenFromHost, getGuacamoleTokenFromHost,
} from "../../src/frontend/guacamole-api"; } 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(() => { beforeEach(() => {
authApiMock.get.mockClear(); authApiMock.get.mockClear();
authApiMock.post.mockClear(); authApiMock.post.mockClear();
@@ -42,9 +47,7 @@ describe("guacamole API origin", () => {
await getGuacdStatus("remote"); await getGuacdStatus("remote");
await getGuacamoleTokenFromHost(9, "remote", "vnc"); await getGuacamoleTokenFromHost(9, "remote", "vnc");
expect(authApiMock.get).toHaveBeenCalledWith( expect(authApiMock.get).toHaveBeenCalledWith("/status");
"/plugin-api/remote-desktop/status",
);
expect(authApiMock.post).toHaveBeenCalledOnce(); expect(authApiMock.post).toHaveBeenCalledOnce();
expect(remoteApiMock.get).not.toHaveBeenCalled(); expect(remoteApiMock.get).not.toHaveBeenCalled();
expect(remoteApiMock.post).not.toHaveBeenCalled(); expect(remoteApiMock.post).not.toHaveBeenCalled();
@@ -60,9 +63,7 @@ describe("guacamole API origin", () => {
expect(status.guacd.status).toBe("connected"); expect(status.guacd.status).toBe("connected");
expect(token.token).toBe("remote-token"); expect(token.token).toBe("remote-token");
expect(remoteApiMock.get).toHaveBeenCalledWith( expect(remoteApiMock.get).toHaveBeenCalledWith("/status");
"/plugin-api/remote-desktop/status",
);
expect(remoteApiMock.post).toHaveBeenCalledOnce(); expect(remoteApiMock.post).toHaveBeenCalledOnce();
expect(authApiMock.get).not.toHaveBeenCalled(); expect(authApiMock.get).not.toHaveBeenCalled();
expect(authApiMock.post).not.toHaveBeenCalled(); expect(authApiMock.post).not.toHaveBeenCalled();
@@ -84,15 +85,12 @@ describe("guacamole API origin", () => {
"host-sync-id", "host-sync-id",
); );
expect(remoteApiMock.post).toHaveBeenCalledWith( expect(remoteApiMock.post).toHaveBeenCalledWith("/connect-host/41", {
"/plugin-api/remote-desktop/connect-host/41", protocol: "rdp",
{ promptedUsername: "admin",
protocol: "rdp", promptedPassword: "secret",
promptedUsername: "admin", promptedDomain: "EXAMPLE",
promptedPassword: "secret", });
promptedDomain: "EXAMPLE",
},
);
expect(resolveRemoteHostIdMock).toHaveBeenCalledWith("host-sync-id"); expect(resolveRemoteHostIdMock).toHaveBeenCalledWith("host-sync-id");
}); });
@@ -105,15 +103,12 @@ describe("guacamole API origin", () => {
domain: "", domain: "",
}); });
expect(authApiMock.post).toHaveBeenCalledWith( expect(authApiMock.post).toHaveBeenCalledWith("/connect-host/9", {
"/plugin-api/remote-desktop/connect-host/9", protocol: "rdp",
{ promptedUsername: "local-admin",
protocol: "rdp", promptedPassword: "secret",
promptedUsername: "local-admin", promptedDomain: "",
promptedPassword: "secret", });
promptedDomain: "",
},
);
}); });
// Support#1240: a host set to "This Device" is served by the embedded // 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"); const token = await getGuacamoleTokenFromHost(9, "local", "rdp");
expect(token.token).toBe("local-token"); expect(token.token).toBe("local-token");
expect(authApiMock.post).toHaveBeenCalledWith( expect(authApiMock.post).toHaveBeenCalledWith("/connect-host/9", {
"/plugin-api/remote-desktop/connect-host/9", protocol: "rdp",
{ });
protocol: "rdp",
},
);
expect(remoteApiMock.post).not.toHaveBeenCalled(); expect(remoteApiMock.post).not.toHaveBeenCalled();
expect(resolveRemoteHostIdMock).not.toHaveBeenCalled(); expect(resolveRemoteHostIdMock).not.toHaveBeenCalled();
}); });
@@ -141,9 +133,7 @@ describe("guacamole API origin", () => {
const status = await getGuacdStatus("local"); const status = await getGuacdStatus("local");
expect(status.guacd.status).toBe("disconnected"); expect(status.guacd.status).toBe("disconnected");
expect(authApiMock.get).toHaveBeenCalledWith( expect(authApiMock.get).toHaveBeenCalledWith("/status");
"/plugin-api/remote-desktop/status",
);
expect(remoteApiMock.get).not.toHaveBeenCalled(); 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); isElectronMock.mockReturnValue(true);
const signal = new AbortController().signal; const signal = new AbortController().signal;
await getGuacamoleConnectionId("connect/id", "remote", signal); await getGuacamoleConnectionId("connect/id", "remote", signal);
expect(remoteApiMock.get).toHaveBeenCalledWith( expect(remoteApiMock.get).toHaveBeenCalledWith("/connection/connect%2Fid", {
"/plugin-api/remote-desktop/connection/connect%2Fid", signal,
{ signal }, });
);
expect(authApiMock.get).not.toHaveBeenCalled(); expect(authApiMock.get).not.toHaveBeenCalled();
}); });
@@ -1,41 +1,51 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createQuickConnectHost } from "@/sidebar/quick-connect-host";
import { quickConnectGuacHost } from "../../src/frontend/quick-connect-guac-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", () => { describe("quickConnectGuacHost", () => {
it("carries an RDP quick connect's address and credentials", () => { it("carries an RDP quick connect's address and credentials", () => {
const host = createQuickConnectHost({ expect(
ip: "10.0.0.2", quickConnectGuacHost(
port: 3390, quickHost("rdp", {
username: "admin", rdpUser: "admin",
authType: "password", rdpPassword: "pw",
password: "pw", domain: "CORP",
protocol: "rdp", }),
domain: "CORP", ),
}); ).toMatchObject({
expect(quickConnectGuacHost(host)).toMatchObject({
ip: "10.0.0.2", ip: "10.0.0.2",
connectionType: "rdp", connectionType: "rdp",
rdpPort: 3390, port: 3390,
rdpUser: "admin", username: "admin",
rdpPassword: "pw", password: "pw",
domain: "CORP", domain: "CORP",
}); });
}); });
it("puts a VNC quick connect's password on the VNC fields", () => { it("takes a VNC quick connect's login from the VNC fields", () => {
const host = createQuickConnectHost({ expect(
ip: "10.0.0.3", quickConnectGuacHost(quickHost("vnc", { vncPassword: "vncpw" })),
port: 5901, ).toMatchObject({
username: "",
authType: "password",
password: "vncpw",
protocol: "vnc",
});
expect(quickConnectGuacHost(host)).toMatchObject({
connectionType: "vnc", connectionType: "vnc",
vncPort: 5901, port: 5901,
vncPassword: "vncpw", password: "vncpw",
}); });
}); });
}); });
-6
View File
@@ -1,10 +1,4 @@
{ {
"extends": "@termix/plugin-sdk/tsconfig.plugin.json", "extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
} }
+1 -1
View File
@@ -24,7 +24,7 @@
"provides": [ "provides": [
{ {
"service": "recordings.writer", "service": "recordings.writer",
"version": "1.0.0", "version": "1.1.0",
"permission": "session-recording.view" "permission": "session-recording.view"
} }
], ],
@@ -5,6 +5,7 @@ import type { SessionRecordingRepository } from "./repository.js";
/** /**
* recordings.writer v1, the service ssh-terminal calls to record sessions. * recordings.writer v1, the service ssh-terminal calls to record sessions.
* 1.1 added enabledFor, for a caller that records outside this plugin.
* *
* One call per already-batched chunk (ssh-terminal coalesces on a 300ms * One call per already-batched chunk (ssh-terminal coalesces on a 300ms
* trailing edge, issue #1049): mkdir and writeFile on the first append, * trailing edge, issue #1049): mkdir and writeFile on the first append,
@@ -22,6 +23,11 @@ export interface RecordingSink {
} }
export interface RecordingsWriterV1 { export interface RecordingsWriterV1 {
/**
* Whether the host's recording switch is on, for a caller that has to set
* up recording before a session exists (remote desktop tells guacd).
*/
enabledFor: (hostId: number) => Promise<boolean>;
/** Null when recording is off for this user or host. */ /** Null when recording is off for this user or host. */
open: (meta: { open: (meta: {
sessionId: string; sessionId: string;
@@ -53,13 +59,15 @@ export function createRecordingsWriter(
ctx: PluginContext, ctx: PluginContext,
repository: SessionRecordingRepository, repository: SessionRecordingRepository,
): RecordingsWriterV1 { ): RecordingsWriterV1 {
const enabledFor = async (hostId: number) =>
(await ctx.settings.getHost<boolean>(hostId, "enableSessionRecording")) !==
false;
return { return {
enabledFor,
async open(meta) { async open(meta) {
const enabled = await ctx.settings.getHost<boolean>( if (!(await enabledFor(meta.hostId))) return null;
meta.hostId,
"enableSessionRecording",
);
if (enabled === false) return null;
const dataDir = await ctx.files.dataDir(); const dataDir = await ctx.files.dataDir();
const dir = path.join(dataDir, "session_logs", meta.userId); const dir = path.join(dataDir, "session_logs", meta.userId);
@@ -171,4 +171,12 @@ describe("recordings.writer", () => {
expect(row.id).toBeGreaterThan(0); expect(row.id).toBeGreaterThan(0);
}); });
it("enabledFor follows the host's recording switch", async () => {
const { mock, writer } = await setup();
await mock.ctx.settings.setHost(7, "enableSessionRecording", true);
await expect(writer.enabledFor(7)).resolves.toBe(true);
await mock.ctx.settings.setHost(7, "enableSessionRecording", false);
await expect(writer.enabledFor(7)).resolves.toBe(false);
});
}); });
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
hostProtocols,
useHosts, useHosts,
useTranslation, useTranslation,
type PluginHostRecord, type PluginHostRecord,
@@ -64,12 +65,17 @@ import {
type DirectoryRole, type DirectoryRole,
} from "./api"; } from "./api";
type SharedProtocol = "ssh" | "rdp" | "vnc" | "telnet";
function isSharedProtocol(value: string): value is SharedProtocol {
return (
value === "ssh" || value === "rdp" || value === "vnc" || value === "telnet"
);
}
/** A host from the shell's list; the connection fields ride along by name. */ /** A host from the shell's list; the connection fields ride along by name. */
type SSHHostWithStatus = PluginHostRecord & { type SSHHostWithStatus = PluginHostRecord & {
enableSsh?: boolean; enableSsh?: boolean;
enableRdp?: boolean;
enableVnc?: boolean;
enableTelnet?: boolean;
connectionOrigin?: unknown; connectionOrigin?: unknown;
}; };
@@ -350,11 +356,13 @@ export function CollabRoomTab({
} }
const response = await createRemoteSessionToken( const response = await createRemoteSessionToken(
Number(host.id), Number(host.id),
await resolveConnectionOrigin({ await resolveConnectionOrigin(
connectionType: protocol, {
connectionOrigin: host.connectionOrigin as connectionOrigin: host.connectionOrigin as
"local" | "remote" | undefined, "local" | "remote" | undefined,
}), },
{ defaultRemote: true },
),
protocol, protocol,
); );
if (!response?.connectionId) { if (!response?.connectionId) {
@@ -780,11 +788,9 @@ export function CollabRoomTab({
<CenteredNote text={t("collab.noHostsFound")} /> <CenteredNote text={t("collab.noHostsFound")} />
)} )}
{filteredHosts.map((host) => { {filteredHosts.map((host) => {
const protocols: Array<"ssh" | "rdp" | "vnc" | "telnet"> = []; const protocols = hostProtocols(
if (host.enableSsh) protocols.push("ssh"); host as unknown as PluginHostRecord,
if (host.enableRdp) protocols.push("rdp"); ).filter(isSharedProtocol);
if (host.enableVnc) protocols.push("vnc");
if (host.enableTelnet) protocols.push("telnet");
if (protocols.length === 0) return null; if (protocols.length === 0) return null;
return ( return (
<div <div
Loaded 100 of 215 files, more files were not shown because too many files have changed in this diff. Show more