mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
release-2.9.0 (#1532)
* fix(macos): skip single-instance lock in Mac App Store builds (#1454)
fix(macos): skip single-instance lock in Mac App Store builds
* Fix/backend optional sharp (#1455)
Fix/backend optional sharp
* feat: issue #1218 (#1218)
https://github.com/Termix-SSH/Support/issues/1218
* feat: Add option to use saved global custom theme under Connection Defaults (#1209)
https://github.com/Termix-SSH/Support/issues/1209
* feat: Add Fleet Sharing functionality to the UI (#1228)
https://github.com/Termix-SSH/Support/issues/1228
* feat: Add a list view to the Docker management page (#1237)
https://github.com/Termix-SSH/Support/issues/1237
* feat: issue #1264 (#1264)
https://github.com/Termix-SSH/Support/issues/1264
* feat: 2FA With FortiToken (#1288)
https://github.com/Termix-SSH/Support/issues/1288
* feat: Allow to disable showing paths to folders (#1274)
https://github.com/Termix-SSH/Support/issues/1274
* feat: Sync Network Graph between Desktop and Remote Server (#1245)
https://github.com/Termix-SSH/Support/issues/1245
* feat: Version Number on offline servers (#1291)
https://github.com/Termix-SSH/Support/issues/1291
* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)
https://github.com/Termix-SSH/Support/issues/1302
* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)
https://github.com/Termix-SSH/Support/issues/1289
* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)
https://github.com/Termix-SSH/Support/issues/1303
* fix: I can't send a file on its own. (#1304)
https://github.com/Termix-SSH/Support/issues/1304
* fix: sudo password not autofilling (#1248)
https://github.com/Termix-SSH/Support/issues/1248
* fix: node-MainThread (#1300)
https://github.com/Termix-SSH/Support/issues/1300
* fix: Tunnel authentication with shared credentials (#1295)
https://github.com/Termix-SSH/Support/issues/1295
* fix: host key updates not syncing, metrics 404 race on first connect
Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.
* chore: increment ver
* feat: add category and icon fields to plugin manifest schema
* ci(deps): bump the github-actions group with 2 updates (#1458)
Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).
Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)
Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)
---
updated-dependencies:
- dependency-name: crowdin/github-action
dependency-version: '3'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
- dependency-name: actions/github-script
dependency-version: '9'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)
Bumps the dev-patch-updates group with 13 updates:
| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |
Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)
Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)
Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)
Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)
Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)
Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)
Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)
Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)
Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)
Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)
Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)
Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)
Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)
---
updated-dependencies:
- dependency-name: "@codemirror/commands"
dependency-version: 6.11.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
dependency-version: 6.7.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
dependency-version: 6.43.12
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
dependency-version: 10.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
dependency-version: 16.3.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
dependency-version: 14.6.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
dependency-version: 1.15.6
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
dependency-version: 6.1.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: cytoscape
dependency-version: 3.34.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
dependency-version: 0.5.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: i18next
dependency-version: 26.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: prettier
dependency-version: 3.9.8
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: react-i18next
dependency-version: 17.0.14
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)
Bumps the prod-patch-updates group with 6 updates:
| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |
Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)
Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)
Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)
Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)
Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)
Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)
---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
dependency-version: 3.14.13
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: compression
dependency-version: 1.8.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jose
dependency-version: 6.2.12
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jszip
dependency-version: 3.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: socks
dependency-version: 2.8.10
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: undici
dependency-version: 8.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* feat: add plugin worker protocol, bootstrap and plugin_storage table
Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.
* feat: add allowlisted host view for plugins
An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.
* feat: add plugin loader with manifest validation and crash restart
Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.
The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.
loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.
* feat: route internal events through a shared plugin event bus
Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.
Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.
* feat: add plugin ctx broker with permission gate and audit
Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.
Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.
fix: stop plugin workers seeing plaintext credentials in error messages
A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:
Authentication failed for root using password <SECRET>
where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.
Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.
Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.
* feat: dispatch plugin HTTP routes and wire the runtime into startup
ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.
Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.
fix: correct the nginx plugin-api location regex
The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.
* fix: update stale SFTP transfer test for single file destination paths
The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.
* feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
* feat: ship docker as a first-party in-process plugin
Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
* feat: ship host metrics as a first-party in-process plugin
* fix: harden plugin auth and add per-request identity for ctx calls
Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.
* feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
* fix: remove compiled plugin JS left behind in plugins/ after build
copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.
* feat: add a permission-gated backend service registry for plugins
* feat: add a permission-gated UI action registry and action slots for plugins
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
* feat: add cross-plugin secret references gated by the provider's RBAC permission
* feat: move proxmox discovery/import/sync into a first-party plugin
* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin
Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
* feat: wire remote-desktop plugin into core and rename i18n keys
Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.
* feat: move fleets into a first-party plugin
* feat: move automations into a first-party plugin
* feat: remove legacy alert rules, superseded by automations
Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.
* feat: move network topology into a first-party plugin
* feat: move tailscale device discovery and settings into a plugin
Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.
* feat: move workspaces into a first-party plugin
* feat: move web endpoint into a first-party plugin
* feat: collapse plugin runtime to one tier behind a plugin SDK
* feat: make each plugin an npm workspace built by a termix-plugin CLI
* feat: give plugins their own tables, migrations and sync entities
* feat: serve plugin http and websockets from one mount instead of ports
* feat: namespace plugin permissions and keep them valid when a plugin is off
* feat: give plugins their own settings and one settings screen
* feat: load plugin frontends at runtime and remove plugin ids from the shell
* fix: mock the ai status fetch in its activate test
* feat: add auth extension points and one ssh connect pipeline
* feat: rebuild workspaces as the reference plugin
* fix: gate second factors after SSO/LDAP behind an admin setting
* feat: convert network-topology to a full plugin (B1)
* feat: move snippets backend and tables into a plugin (B2)
* feat: point core frontend snippets pickers at the plugin (B2)
* feat: add the snippets plugin (B2)
* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)
* feat: finish the tailscale plugin, including SSH auth (B3)
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* chore: update proxmox and host-metrics locale files for B5
* chore: regenerate drizzle migration journals for B5
* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox
* feat: move proxmox node stats and collectors into the proxmox plugin
* feat: drop proxmox host columns, use plugin settings instead
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* feat: convert file manager into a plugin
* feat: convert ssh tunnels into a plugin
* feat: finish the web-endpoint plugin, add ctx.desktop (B8)
* feat: move the terminal fully into the ssh-terminal plugin (B9)
* feat: convert tmux monitoring into a plugin (B10)
* feat: convert serial console into the serial plugin
* feat: move session sharing and collab rooms into a plugin (B12)
* feat: turn session recording into a plugin (B13)
* feat: finish the remote-desktop plugin (B14)
* feat: finish the host-metrics plugin, move host status into core (B16)
* feat: finish the docker plugin (B15)
* feat: finish the automations plugin (B17)
* feat: finish the ai plugin (B18)
* feat: finish the homepage plugin, move service links and canvas out of core (B19)
* feat: convert wake-on-lan into a plugin (B20)
* feat: convert secret-sources into a plugin (B20)
* feat: move totp and passkeys into plugins (C1)
* feat: move sso and ldap logins into plugins (C2)
* feat: move opkssh and warpgate into plugins (C3)
* feat: move step-ca into a plugin (C4)
* feat: move vault ssh signing into a plugin (C5)
* feat: move termix identity into a plugin (C6)
* feat: move acme certificate automation into a plugin (C7)
* fix: finish 2.9.0 plugin moves and get every check green
* chore: lock the plugin boundary and remove 2.9.0 leftovers
* fix: harden the plugin runtime after the 2.9.0 security review
* feat: add plugin packing, signing, pinned trust and tmxplug bundling
* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught
* fix: give bundled plugin backends a real require for CJS deps
* feat: improve UI loading time and jitter
* feat: show credential sharing inline in the sidebar like host sharing
* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning
* feat: unify alerts into one plugin, channels, rules and inbox
* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
* fix: set blacksmith builder cache-key to stop broken gha cache fallback
* fix: link plugin-sdk workspace before npm ci in docker deps stage
* fix: link every plugin workspace before npm ci in docker deps stage
* fix(host-metrics): exclude image and firmware filesystems (#1469)
* fix(host-metrics): exclude image and firmware filesystems
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(hosts): normalize imported jump host references (#1470)
* fix(hosts): normalize imported jump host references
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent (#1476)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent
* fix(dashboard): reuse translated host status labels (#1475)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(dashboard): reuse translated host status labels
* docs(api): clarify local and remote version fields (#1474)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* docs(api): clarify local and remote version fields
* fix(file-manager): preserve selected backgrounds on hover (#1473)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): preserve selection feedback on hover
* test(ui): await dialog focus cleanup before teardown
* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): focus file search with Ctrl or Cmd F
* fix: keep rounded-full circular in production builds
* fix(file-manager): cancel uploads and stop remote writes (#1477)
* fix(file-manager): cancel uploads and stop remote writes
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): apply compact density to directory tree (#1478)
* fix(file-manager): apply compact density to directory tree
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): preserve font sizes in compact mode (#1480)
* fix(file-manager): preserve font sizes in compact mode
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): prioritize file names in narrow lists (#1483)
* fix(file-manager): prioritize file names in narrow lists
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(remote-desktop): probe the saved host guacd endpoint (#1481)
* fix(remote-desktop): probe the saved host guacd endpoint
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* fix(homepage): check allowlisted private service reachability (#1479)
* fix(homepage): check allowlisted private service reachability
* test(css): exercise the configured PostCSS plugin chain
* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)
* fix(remote-desktop): read clipboard only on explicit paste
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* chore: simplify plugin readmes, changelogs and manifest descriptions
* fix: keep plugins loaded after signing in from the login screen
* fix(file-manager): accept OS file drops over existing rows (#1484)
* fix(file-manager): accept OS file drops over existing rows
* test(file-manager): target the file label in drop tests
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(file-manager): pipeline SFTP download reads (#1485)
* feat: add plugin sdk build to beta release
* fix: make link-store secret check immune to random base64 matches
* fix(ldap): preserve the directory-provided bind DN (#1486)
* fix: compute beta changes across dev branches and move the beta tag
* chore: untrack plugin sdk ARCHITECTURE.md
* chore: remove references to plugin sdk ARCHITECTURE.md
* feat: merge host editor terminal tabs into one owned by ssh-terminal
* feat: add descriptions to plugin host enable toggles
* fix(file-manager): show download progress in file viewers (#1487)
* fix(electron): signal backend readiness over IPC (#1488)
* fix(split-screen): allow layout creation without randomUUID (#1490)
* fix(split-screen): allow layout creation without randomUUID
* test(credentials): avoid flaky Ed25519 key generation
* fix(sidebar): remeasure visible rows after density changes (#1489)
* fix(sidebar): remeasure visible rows after density changes
* test(credentials): avoid flaky Ed25519 key generation
* feat(split-screen): redesign split screen with free-form panes and pane picker
* feat(sidebar): add host click behavior setting, default to always opening a new tab
* feat(snippets): redesign panel to match the host list
* fix(session-recording): stop session logs panel from refetching in a loop
* feat(snippets): bring back sharing, target hosts and drag reorder
* feat(ssh-terminal): add toolbar position, start state, display and fade host settings
* feat(ssh-terminal): add host setting to turn off password prompt auto-fill
* fix(ai): stop streams getting cut off and keep tool steps across messages
* feat: improve quick connect system using plugin api
* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport
* feat: move terminal settings, look and ssh tools into ssh-terminal plugin
* feat: move snippets, keybinding actions and macros out of core into plugins
* feat: store rdp, vnc and telnet logins in a generic per-protocol table
* feat: type the plugin host record, move plugin strings out of core and add lint checks
* feat: replace core homepage widget registry with generic extension points
* feat: plugins only see their own host settings in the browser
* feat: harden plugin api for separate repos, signed updates and generic external login
* fix: keep plugin css below core utilities and version plugin assets by their css
* fix: load and save plugin host settings in the editor and tighten settings row spacing
* feat: redesign user host defaults system
* fix(remote-desktop): release held keys on focus loss (#1498)
* fix(session-sharing): replace local presentation after takeover (#1500)
* fix(file-manager): refresh disk usage with directory listing (#1503)
* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)
* feat(remote-desktop): expose display zoom controls for RDP (#1514)
* fix(file-manager): restore text selection and copying in Markdown previews (#1518)
* fix(sidebar): keep compact host addresses visible (#1524)
* fix(hosts): preserve plugin settings when reopening the editor (#1499)
* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)
* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)
* fix(session-sharing): show participant presence in member terminals (#1504)
* fix(ai): sync provider configuration and keys across linked devices (#1506)
* feat(ssh-terminal): add a duplicate tab action (#1510)
* feat(file-manager): allow disabling move-to-trash confirmation (#1511)
* feat(file-manager): default to the remote login directory (#1512)
* feat(homepage): synchronize canvas layouts across devices (#1515)
* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)
* feat(tmux-monitor): add a unified multi-host view (#1520)
* fix(electron): allow explicit quit with active connections (#1522)
* feat: reconnect all disconnected terminal tabs (#1523)
* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)
* fix(database): preserve RDP domain during startup backfill (#1497)
* feat(sidebar): add keyboard type-ahead search for hosts (#1516)
* feat(automations): add per-host maintenance mode and recurring schedules (#1521)
* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)
* fix(session-sharing): use the linked server for desktop meetings and links (#1507)
* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)
* fix: type errors and lint failures from merged plugin prs
* feat(installer): make the Windows desktop shortcut optional (#1517)
* fix: ship the plugin sdk and serialport in desktop builds
* fix: prefer ipv4 when resolving ssh host names (#1365)
* fix: readable terminal selection with the light theme (#1319)
* fix: build desktop session share links on the linked server (#1350)
* fix: show meeting guests to members and hide the roster from guests (#1353)
* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)
* fix: show disk usage for the mount being browsed (#1347)
* fix: load the linux tray icon reliably and log tray failures (#1332)
* fix: cover the gnome close path in the desktop quit test (#1332)
* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)
* feat: optional auto reconnect for dropped ssh sessions (#1321)
* feat: tighter compact host rows with inline tags (#1325)
* feat: bring back the alert feed homepage widget
* feat: pick target terminals for snippets again
* feat: share button on connection tabs again
* feat: recent servers list when linking the desktop app
* feat: list the fixed app shortcuts in keyboard shortcuts
* fix: translate hardcoded toasts and drop em dashes from ui text
* fix: translate leftover hardcoded ui text
* fix: toolbar files button opens at the shell directory again
* fix: show the remote desktop note under connection origin again
* chore: openapi docs for host metrics manager routes
* chore: openapi docs for undocumented routes and fix two broken doc blocks
* fix: declare terminal.listSessions use for snippet targets
* revert: drop the alert feed widget, the notification center replaces it
* feat: restore the alert feed homepage widget
* fix: quick connect fills the host address from plugin auth editors like tailscale
* fix: docker container logs use the base background color
* chore: fix dependabot alerts and apply safe npm upgrades
* fix: proxmox stats tab reads its switch from plugin host settings
* fix: count plugin ssh logins toward host status and stop showing reachable as a warning
* fix: hide the built-in source pill in the host editor
* fix: host status shows online when the port answers and only warns on a failed login
* fix: drop the top border on the split layout menu
* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default
* fix: remote desktop auth method labels and notes for vnc and telnet
* fix: add description to the web endpoint enable switch
* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release
* feat: move telemetry out of core into a usage statistics plugin
* fix: recent activity card no longer cuts off host names early
* fix: remove duplicate top border on split screen menu
* fix: redesign session log player controls, fix tooltip overlap and drop dot separators
* fix: file manager and sftp text showing raw i18n keys
* fix: onboarding rerunning for existing users after upgrading
* fix: fleet detail buttons overflowing the sidebar
* fix: session log player scrolling off screen horizontally
* fix: network graph lag when adding hosts and on every render
* fix: maintenance index name, shared host read only view and badge polling
* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag
* chore: update release notes
* fix: remote desktop tabs reconnecting on every tab switch
* fix: build plugin sdk before linting in release workflow
* chore: lint, format, and bump version to 2.9.0
* chore: skip crowdin pre-translate in release for now
* chore: sync Crowdin translations for 2.9.0
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
4405 files changed
+1341026
-289722
No files matched your search
@@ -0,0 +1,29 @@
|
||||
export const GUACD_DRIVE_PATH_ENV = "GUACD_DRIVE_PATH";
|
||||
const DEFAULT_DRIVE_ROOT = "/drive";
|
||||
|
||||
/**
|
||||
* Fills in where guacd keeps the files behind RDP drive redirection.
|
||||
*
|
||||
* The folder lives on the guacd host, not on Termix's - with the stock
|
||||
* compose it is a directory in the shared termix-data volume, configured
|
||||
* through GUACD_DRIVE_PATH. Each user gets a folder of their own underneath:
|
||||
* a shared drive would show everyone's uploads to everyone else. A host that
|
||||
* names its own drive-path keeps it.
|
||||
*/
|
||||
export function withDriveSettings(
|
||||
guacConfig: Record<string, unknown>,
|
||||
userId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Record<string, unknown> {
|
||||
if (!guacConfig["enable-drive"] || guacConfig["drive-path"]) {
|
||||
return guacConfig;
|
||||
}
|
||||
const root = (
|
||||
env[GUACD_DRIVE_PATH_ENV]?.trim() || DEFAULT_DRIVE_ROOT
|
||||
).replace(/\/+$/, "");
|
||||
return {
|
||||
...guacConfig,
|
||||
"drive-path": `${root}/${userId}`,
|
||||
"create-drive-path": true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import GuacamoleLite from "guacamole-lite";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { Duplex } from "stream";
|
||||
import type {
|
||||
GuacamoleRecordingMetadata,
|
||||
GuacamoleTokenService,
|
||||
TermixGuacMeta,
|
||||
} from "./token-service.js";
|
||||
import type { GuacdOptions } from "./guacd-config.js";
|
||||
import type { RemoteSessions } from "./sessions.js";
|
||||
import { errorMessage, type RemoteDesktopLogger } from "./log.js";
|
||||
|
||||
/** The recordings.writer service, when the session-recording plugin provides it. */
|
||||
export interface RecordingsWriter {
|
||||
enabledFor: (hostId: number) => Promise<boolean>;
|
||||
createFinished: (input: {
|
||||
hostId: number;
|
||||
userId: string;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
duration: number | null;
|
||||
recordingPath: string;
|
||||
protocol: string;
|
||||
format: string;
|
||||
}) => Promise<{ id: number }>;
|
||||
}
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||
|
||||
/** Where the backend reads guacd's recordings from. */
|
||||
export function recordingsDir(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return (
|
||||
env.GUACD_RECORDING_BACKEND_PATH ||
|
||||
path.join(DATA_DIR, "session_recordings", "guacamole")
|
||||
);
|
||||
}
|
||||
|
||||
type ClientConnection = {
|
||||
guacamoleConnectionId?: string;
|
||||
connectionSettings?: {
|
||||
connection?: { type?: string; join?: string; readOnly?: boolean };
|
||||
recording?: GuacamoleRecordingMetadata;
|
||||
termixMeta?: TermixGuacMeta;
|
||||
};
|
||||
close?: () => void;
|
||||
};
|
||||
|
||||
const clientOptions = (
|
||||
tokens: GuacamoleTokenService,
|
||||
log: RemoteDesktopLogger,
|
||||
) => ({
|
||||
crypt: {
|
||||
cypher: "AES-256-CBC",
|
||||
key: tokens.getEncryptionKey(),
|
||||
},
|
||||
log: {
|
||||
level: "ERRORS",
|
||||
stdLog: (...args: unknown[]) => log.info(args.join(" ")),
|
||||
errorLog: (...args: unknown[]) => log.error(args.join(" ")),
|
||||
},
|
||||
allowedUnencryptedConnectionSettings: {
|
||||
rdp: ["width", "height", "dpi"],
|
||||
vnc: ["width", "height"],
|
||||
telnet: ["width", "height"],
|
||||
},
|
||||
connectionDefaultSettings: {
|
||||
rdp: {
|
||||
security: "any",
|
||||
"ignore-cert": true,
|
||||
"enable-wallpaper": false,
|
||||
"enable-font-smoothing": true,
|
||||
"enable-desktop-composition": false,
|
||||
"disable-audio": false,
|
||||
"enable-drive": false,
|
||||
"resize-method": "display-update",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
dpi: 96,
|
||||
audio: ["audio/L16"],
|
||||
},
|
||||
vnc: {
|
||||
"swap-red-blue": false,
|
||||
cursor: "remote",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
// macOS Screen Sharing negotiates its VNC security type over several
|
||||
// round trips (RFB type 30 -> 33/36/2/35) and can fail the first
|
||||
// attempt; retrying lets guacd's VNC client re-establish instead of
|
||||
// guacd giving up immediately (Support#1063).
|
||||
autoretry: 2,
|
||||
},
|
||||
telnet: {
|
||||
"terminal-type": "xterm-256color",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export interface GuacamoleServerDeps {
|
||||
log: RemoteDesktopLogger;
|
||||
tokens: GuacamoleTokenService;
|
||||
sessions: RemoteSessions;
|
||||
guacd: () => GuacdOptions;
|
||||
recordings: () => RecordingsWriter | null;
|
||||
/** A socket event has no request behind it, so the row is written as the session's user. */
|
||||
asUser: <T>(userId: string, fn: () => Promise<T>) => Promise<T>;
|
||||
/** Called when a primary session opens; returns what to release on close. */
|
||||
onOpen?: (hostId: number) => () => void;
|
||||
}
|
||||
|
||||
export interface GuacamoleServer {
|
||||
handleUpgrade: (
|
||||
request: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
) => void;
|
||||
/** Rebuilds the server so a changed guacd address takes effect. */
|
||||
restart: () => void;
|
||||
/** Closes a session and everyone watching it. */
|
||||
endSession: (guacamoleConnectionId: string) => boolean;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* guacamole-lite in noServer mode: core owns the listening socket and hands
|
||||
* upgrades from /plugin-ws/remote-desktop/display to its WebSocketServer.
|
||||
*/
|
||||
export function createGuacamoleServer(
|
||||
deps: GuacamoleServerDeps,
|
||||
): GuacamoleServer {
|
||||
const { log, sessions } = deps;
|
||||
let server: GuacamoleLite | null = null;
|
||||
|
||||
const persistRecording = async (connection: ClientConnection) => {
|
||||
const recording = connection.connectionSettings?.recording;
|
||||
if (!recording) return;
|
||||
|
||||
const dir = recordingsDir();
|
||||
const resolvedPath = path.resolve(dir, recording.path);
|
||||
if (!resolvedPath.startsWith(`${path.resolve(dir)}${path.sep}`)) return;
|
||||
|
||||
// guacd may flush or rename the recording just after the socket closes.
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 10 && !fs.existsSync(resolvedPath);
|
||||
attempt++
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
log.warn("Guacamole recording file was not found", {
|
||||
operation: "guac_recording_missing",
|
||||
hostId: recording.hostId,
|
||||
path: resolvedPath,
|
||||
guacdPath: recording.guacdPath ?? dir,
|
||||
hint:
|
||||
"guacd writes the recording to guacdPath, the backend reads it from path. " +
|
||||
"When guacd runs in its own container these must be the same volume: set " +
|
||||
"GUACD_RECORDING_PATH to guacd's mount point and GUACD_RECORDING_BACKEND_PATH to this one.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const writer = await deps.asUser(recording.userId, async () =>
|
||||
deps.recordings(),
|
||||
);
|
||||
if (!writer) {
|
||||
log.warn(
|
||||
"Session Recording is off; the recording was kept on disk only",
|
||||
{
|
||||
operation: "guac_recording_no_writer",
|
||||
hostId: recording.hostId,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const endedAt = new Date();
|
||||
const startedAt = new Date(recording.startedAt);
|
||||
await deps.asUser(recording.userId, () =>
|
||||
writer.createFinished({
|
||||
hostId: recording.hostId,
|
||||
userId: recording.userId,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString(),
|
||||
duration: Math.max(
|
||||
0,
|
||||
Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000),
|
||||
),
|
||||
recordingPath: resolvedPath,
|
||||
protocol: recording.protocol,
|
||||
format: "guacamole",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const build = (): GuacamoleLite => {
|
||||
// guacamole-lite fills in port 8080 unless it sees a server key, and ws
|
||||
// refuses a port next to noServer, so the port is cleared explicitly.
|
||||
const guacamole = new GuacamoleLite(
|
||||
{ noServer: true, port: undefined },
|
||||
deps.guacd(),
|
||||
clientOptions(deps.tokens, log),
|
||||
);
|
||||
// It also installs its own SIGTERM and SIGINT handlers. Core decides
|
||||
// when the process stops and closes this through deactivate instead.
|
||||
const handlers = guacamole as unknown as {
|
||||
sigTermHandler?: () => void;
|
||||
sigIntHandler?: () => void;
|
||||
};
|
||||
if (handlers.sigTermHandler) {
|
||||
process.off("SIGTERM", handlers.sigTermHandler);
|
||||
}
|
||||
if (handlers.sigIntHandler) {
|
||||
process.off("SIGINT", handlers.sigIntHandler);
|
||||
}
|
||||
|
||||
guacamole.on("open", (connection: ClientConnection) => {
|
||||
const meta = connection.connectionSettings?.termixMeta;
|
||||
const guacamoleConnectionId = connection.guacamoleConnectionId;
|
||||
const isJoin = !!connection.connectionSettings?.connection?.join;
|
||||
if (isJoin || !meta || !guacamoleConnectionId) return;
|
||||
const release = deps.onOpen?.(meta.hostId);
|
||||
sessions.opened(meta, guacamoleConnectionId, release ? [release] : []);
|
||||
});
|
||||
|
||||
guacamole.on("close", (connection: ClientConnection) => {
|
||||
const meta = connection.connectionSettings?.termixMeta;
|
||||
const isJoin = !!connection.connectionSettings?.connection?.join;
|
||||
if (!isJoin && meta) sessions.closed(meta.termixConnectId);
|
||||
persistRecording(connection).catch((error) => {
|
||||
log.error("Failed to persist Guacamole recording", {
|
||||
operation: "guac_recording_persist_error",
|
||||
error: errorMessage(error),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
guacamole.on("error", (connection: ClientConnection, error: Error) => {
|
||||
log.error("Guacamole connection error", {
|
||||
operation: "guac_connection_error",
|
||||
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) {
|
||||
// The route is public, so the token is the only credential: refuse
|
||||
// anything forged, edited or expired before guacamole-lite reads it.
|
||||
const token = new URL(
|
||||
request.url ?? "/",
|
||||
"http://localhost",
|
||||
).searchParams.get("token");
|
||||
if (!token || !deps.tokens.verifyToken(token)) {
|
||||
try {
|
||||
socket.write(
|
||||
"HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n",
|
||||
);
|
||||
} catch {
|
||||
// The peer may already be gone.
|
||||
}
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
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);
|
||||
});
|
||||
},
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
export type GuacdOptions = {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
const DEFAULT_GUACD_OPTIONS: GuacdOptions = {
|
||||
host: "localhost",
|
||||
port: 4822,
|
||||
};
|
||||
|
||||
function parsePort(value: string | undefined, fallback: number) {
|
||||
const port = parseInt(value || "", 10);
|
||||
return Number.isFinite(port) ? port : fallback;
|
||||
}
|
||||
|
||||
export function parseGuacdUrl(
|
||||
value: string,
|
||||
fallback: GuacdOptions = DEFAULT_GUACD_OPTIONS,
|
||||
): GuacdOptions {
|
||||
const raw = value.trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (raw.includes("://")) {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return {
|
||||
host: url.hostname || fallback.host,
|
||||
port: parsePort(url.port, fallback.port),
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const bracketedIpv6 = raw.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
||||
if (bracketedIpv6) {
|
||||
return {
|
||||
host: bracketedIpv6[1],
|
||||
port: parsePort(bracketedIpv6[2], fallback.port),
|
||||
};
|
||||
}
|
||||
|
||||
const [host, port] = raw.split(":");
|
||||
return {
|
||||
host: host || fallback.host,
|
||||
port: parsePort(port, fallback.port),
|
||||
};
|
||||
}
|
||||
|
||||
export function getGuacdEnvOptions(): GuacdOptions | null {
|
||||
if (process.env.GUACD_URL) {
|
||||
return parseGuacdUrl(process.env.GUACD_URL);
|
||||
}
|
||||
|
||||
if (!process.env.GUACD_HOST && !process.env.GUACD_PORT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
host: process.env.GUACD_HOST || DEFAULT_GUACD_OPTIONS.host,
|
||||
port: parsePort(process.env.GUACD_PORT, DEFAULT_GUACD_OPTIONS.port),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGuacdOptions(dbUrl?: string | null): GuacdOptions {
|
||||
const envOptions = getGuacdEnvOptions();
|
||||
if (envOptions) {
|
||||
return envOptions;
|
||||
}
|
||||
|
||||
if (dbUrl) {
|
||||
return parseGuacdUrl(dbUrl);
|
||||
}
|
||||
|
||||
return DEFAULT_GUACD_OPTIONS;
|
||||
}
|
||||
|
||||
export function formatGuacdOptions(options: GuacdOptions): string {
|
||||
return `${options.host}:${options.port}`;
|
||||
}
|
||||
|
||||
export function getDefaultGuacdUrl(): string {
|
||||
return formatGuacdOptions(resolveGuacdOptions());
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered as "remote-desktop.hostPayloadLegacy": each protocol's login
|
||||
* in the flat 2.8 shape (rdpUser, rdpDomain, vncAuthType and so on), for
|
||||
* Termix-Mobile until it reads protocolAuth. Built from the payload's own
|
||||
* protocolAuth, which core already reduced for a shared recipient.
|
||||
*/
|
||||
export function hostPayloadLegacy(
|
||||
_values: Record<string, unknown>,
|
||||
host: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const logins = asObject(host.protocolAuth);
|
||||
if (!logins) return null;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
|
||||
const login = asObject(logins[protocol]);
|
||||
if (!login) continue;
|
||||
out[`${protocol}AuthType`] = login.authType;
|
||||
if (login.credentialId !== undefined) {
|
||||
out[`${protocol}CredentialId`] = login.credentialId;
|
||||
}
|
||||
if (typeof login.username === "string" && login.username) {
|
||||
out[`${protocol}User`] = login.username;
|
||||
}
|
||||
if (typeof login.hasPassword === "boolean") {
|
||||
const name = protocol.charAt(0).toUpperCase() + protocol.slice(1);
|
||||
out[`has${name}Password`] = login.hasPassword;
|
||||
}
|
||||
const domain = asObject(login.fields)?.domain;
|
||||
if (protocol === "rdp" && typeof domain === "string" && domain) {
|
||||
out.rdpDomain = domain;
|
||||
out.domain = domain;
|
||||
}
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
|
||||
/** Display field 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",
|
||||
};
|
||||
|
||||
/**
|
||||
* Display settings 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The display settings a session runs with, as guacd parameters: the host's
|
||||
* as this user sees them (their own defaults while the host follows its
|
||||
* defaults), or with no host, this user's defaults.
|
||||
*/
|
||||
export async function readDisplayDefaults(
|
||||
ctx: PluginContext,
|
||||
userId: string,
|
||||
hostId?: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(USER_DEFAULT_PARAMS)) {
|
||||
values[key] =
|
||||
hostId === undefined
|
||||
? await ctx.settings.getHostDefault(userId, key)
|
||||
: await ctx.settings.getHostFor(hostId, userId, key);
|
||||
}
|
||||
return userDefaultParams(values);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { RemoteSessions } from "./sessions.js";
|
||||
import {
|
||||
createGuacamoleServer,
|
||||
type RecordingsWriter,
|
||||
} from "./guacamole-server.js";
|
||||
import { resolveGuacdOptions } from "./guacd-config.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
import { createLiveSessions } from "./live-sessions.js";
|
||||
import {
|
||||
hostPayloadLegacy,
|
||||
normalizeImportedHost,
|
||||
PORT_KEY,
|
||||
readHostSettings,
|
||||
} from "./host-settings.js";
|
||||
import { createLogger } from "./log.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const log = createLogger(ctx.log);
|
||||
const tokens = new GuacamoleTokenService(log);
|
||||
const sessions = new RemoteSessions();
|
||||
ctx.disposables.add(() => sessions.clear());
|
||||
|
||||
// The admin setting, unless GUACD_URL / GUACD_HOST / GUACD_PORT override it.
|
||||
let guacdUrl = (await ctx.settings.get<string>("guacdUrl")) ?? "";
|
||||
const guacd = () => resolveGuacdOptions(guacdUrl || undefined);
|
||||
const enabled = async () =>
|
||||
(await ctx.settings.get<boolean>("enabled")) !== false;
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
// guacamole-lite prints every new connection to stdout.
|
||||
const consoleLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
if (
|
||||
typeof args[0] === "string" &&
|
||||
args[0].startsWith("New client connection")
|
||||
)
|
||||
return;
|
||||
consoleLog(...args);
|
||||
};
|
||||
ctx.disposables.add(() => {
|
||||
console.log = consoleLog;
|
||||
});
|
||||
|
||||
const server = createGuacamoleServer({
|
||||
log,
|
||||
tokens,
|
||||
sessions,
|
||||
guacd,
|
||||
recordings,
|
||||
asUser: (userId, fn) => ctx.asUser(userId, fn),
|
||||
onOpen: (hostId) => ctx.hosts.trackSession(hostId),
|
||||
});
|
||||
ctx.disposables.add(() => server.close());
|
||||
|
||||
ctx.settings.onChange("guacdUrl", (value) => {
|
||||
guacdUrl = typeof value === "string" ? value : "";
|
||||
server.restart();
|
||||
});
|
||||
|
||||
registerRoutes(ctx.http.router(), {
|
||||
ctx,
|
||||
log,
|
||||
tokens,
|
||||
sessions,
|
||||
guacd,
|
||||
enabled,
|
||||
recordings,
|
||||
});
|
||||
|
||||
// guacamole-lite owns its own WebSocketServer, so it takes the raw upgrade.
|
||||
// Public because a display authenticates with the encrypted, single-use
|
||||
// token minted by an authenticated route, not with a session JWT.
|
||||
ctx.ws.upgrade(
|
||||
"/display",
|
||||
(request, socket, head) =>
|
||||
server.handleUpgrade(
|
||||
request as Parameters<typeof server.handleUpgrade>[0],
|
||||
socket as Parameters<typeof server.handleUpgrade>[1],
|
||||
head as Buffer,
|
||||
),
|
||||
{ public: true },
|
||||
);
|
||||
|
||||
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
|
||||
ctx.services.provide(
|
||||
"sessions.live",
|
||||
createLiveSessions(protocol, {
|
||||
sessions,
|
||||
tokens,
|
||||
endSession: server.endSession,
|
||||
}),
|
||||
{ name: protocol },
|
||||
);
|
||||
}
|
||||
|
||||
ctx.registry.provide(
|
||||
"remote-desktop.hostImportNormalizer",
|
||||
normalizeImportedHost,
|
||||
);
|
||||
ctx.registry.provide("remote-desktop.hostPayloadLegacy", hostPayloadLegacy);
|
||||
|
||||
// Core's status dot pings these hosts on the protocol's own port.
|
||||
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
|
||||
ctx.hosts.status.registerPort(protocol, async (hostId) => {
|
||||
const settings = await readHostSettings(ctx, hostId);
|
||||
return settings[PORT_KEY[protocol]] as number;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function deactivate() {}
|
||||
@@ -0,0 +1,15 @@
|
||||
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
|
||||
export function resolveJumpTunnelEndpoint(
|
||||
guacdHost: string,
|
||||
tunnelHost = process.env.GUACD_TUNNEL_HOST,
|
||||
): { bindHost: string; advertisedHost: string } {
|
||||
if (LOOPBACK_HOSTS.has(guacdHost.toLowerCase())) {
|
||||
return { bindHost: "127.0.0.1", advertisedHost: "127.0.0.1" };
|
||||
}
|
||||
|
||||
return {
|
||||
bindHost: "0.0.0.0",
|
||||
advertisedHost: tunnelHost?.trim() || "termix",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import net from "net";
|
||||
import type { Client } from "ssh2";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { RemoteDesktopLogger } from "./log.js";
|
||||
|
||||
/** The tunnels plugin's service, as ctx.services.get("tunnels.access") returns it. */
|
||||
interface TunnelsAccess {
|
||||
forward(
|
||||
sourceHostId: number,
|
||||
target: { targetHost: string; targetPort: number; bindHost?: string },
|
||||
options?: { name?: string; idleTimeoutMs?: number },
|
||||
): Promise<{
|
||||
bindHost: string;
|
||||
bindPort: number;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** The tunnels plugin keeps these names for on-demand forwards. */
|
||||
const TUNNEL_NAME_PREFIX = "web:";
|
||||
/** A forward nothing is using closes itself after this long. */
|
||||
const FORWARD_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface JumpTunnel {
|
||||
port: number;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface JumpTunnelRequest {
|
||||
jumpHosts: Array<{ hostId: number }>;
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
bindHost: string;
|
||||
/** Names the forward, so each session gets one of its own. */
|
||||
connectId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A local port guacd can dial that reaches the target through the host's
|
||||
* jump hosts.
|
||||
*
|
||||
* One hop is exactly what the tunnels plugin's forward does, so that is used
|
||||
* when the plugin is running and the user may use it. A chain, or no tunnels
|
||||
* plugin, goes through ctx.ssh.jumpChain with a listener of our own. Either
|
||||
* way the caller closes it when the session ends.
|
||||
*/
|
||||
export async function openJumpTunnel(
|
||||
ctx: PluginContext,
|
||||
log: RemoteDesktopLogger,
|
||||
request: JumpTunnelRequest,
|
||||
): Promise<JumpTunnel> {
|
||||
if (request.jumpHosts.length === 1) {
|
||||
const viaTunnels = await forwardThroughTunnels(ctx, log, request);
|
||||
if (viaTunnels) return viaTunnels;
|
||||
}
|
||||
return forwardThroughChain(ctx, request);
|
||||
}
|
||||
|
||||
async function forwardThroughTunnels(
|
||||
ctx: PluginContext,
|
||||
log: RemoteDesktopLogger,
|
||||
request: JumpTunnelRequest,
|
||||
): Promise<JumpTunnel | null> {
|
||||
const tunnels = ctx.services.get<Partial<TunnelsAccess>>("tunnels.access");
|
||||
if (typeof tunnels.forward !== "function") return null;
|
||||
const hopId = request.jumpHosts[0].hostId;
|
||||
try {
|
||||
const handle = await tunnels.forward(
|
||||
hopId,
|
||||
{
|
||||
targetHost: request.targetHost,
|
||||
targetPort: request.targetPort,
|
||||
bindHost: request.bindHost,
|
||||
},
|
||||
{
|
||||
name: `${TUNNEL_NAME_PREFIX}${hopId}:rd-${request.connectId}`,
|
||||
idleTimeoutMs: FORWARD_IDLE_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
return {
|
||||
port: handle.bindPort,
|
||||
close: () => void handle.close().catch(() => undefined),
|
||||
};
|
||||
} catch (error) {
|
||||
// A user without the tunnels permission still reaches their host.
|
||||
log.warn("Tunnels forward failed, using a jump chain instead", {
|
||||
operation: "guac_tunnels_forward",
|
||||
hopId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardThroughChain(
|
||||
ctx: PluginContext,
|
||||
request: JumpTunnelRequest,
|
||||
): Promise<JumpTunnel> {
|
||||
// The chain dials the first hop through that hop's own SOCKS5 settings;
|
||||
// the target host's proxy config does not apply to it.
|
||||
const { client, dispose } = await ctx.ssh.jumpChain<Client>(
|
||||
request.jumpHosts,
|
||||
);
|
||||
|
||||
const sockets = new Set<net.Socket>();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
client.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
request.targetHost,
|
||||
request.targetPort,
|
||||
(error, stream) => {
|
||||
if (error) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
socket.pipe(stream).pipe(socket);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
let port: number;
|
||||
try {
|
||||
port = await new Promise<number>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, request.bindHost, () =>
|
||||
resolve((server.address() as net.AddressInfo).port),
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
await dispose();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
close: () => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
server.close();
|
||||
void dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { GuacamoleTokenService } from "./token-service.js";
|
||||
import type { RemoteSessions } from "./sessions.js";
|
||||
import type { RemoteProtocol } from "./host-settings.js";
|
||||
|
||||
/** sessions.live v1, as session-sharing reads it (plugins/session-sharing/src/backend/live.ts). */
|
||||
export interface LiveSessionInfo {
|
||||
id: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
tabInstanceId: string | null;
|
||||
}
|
||||
|
||||
export interface RemoteLiveSessions {
|
||||
getSession: (sessionId: string) => LiveSessionInfo | null;
|
||||
ownerEndSession: (sessionId: string, reason: string) => void;
|
||||
createViewerToken: (sessionId: string, readOnly: boolean) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One provider per protocol. A session id is guacd's own connection id,
|
||||
* which is what a viewer's join token names.
|
||||
*/
|
||||
export function createLiveSessions(
|
||||
protocol: RemoteProtocol,
|
||||
deps: {
|
||||
sessions: RemoteSessions;
|
||||
tokens: GuacamoleTokenService;
|
||||
endSession: (guacamoleConnectionId: string) => boolean;
|
||||
},
|
||||
): RemoteLiveSessions {
|
||||
const find = (sessionId: string) => {
|
||||
const session = deps.sessions.byGuacamole(sessionId);
|
||||
return session && session.protocol === protocol ? session : null;
|
||||
};
|
||||
|
||||
return {
|
||||
getSession(sessionId) {
|
||||
const session = find(sessionId);
|
||||
if (!session) return null;
|
||||
return {
|
||||
id: session.guacamoleConnectionId,
|
||||
userId: session.ownerUserId,
|
||||
hostId: session.hostId,
|
||||
hostName: session.hostName,
|
||||
isConnected: true,
|
||||
createdAt: session.openedAt,
|
||||
tabInstanceId: session.tabInstanceId,
|
||||
};
|
||||
},
|
||||
|
||||
ownerEndSession(sessionId) {
|
||||
if (find(sessionId)) deps.endSession(sessionId);
|
||||
},
|
||||
|
||||
createViewerToken(sessionId, readOnly) {
|
||||
if (!find(sessionId)) {
|
||||
throw new Error(`No live ${protocol} session ${sessionId}`);
|
||||
}
|
||||
return deps.tokens.createJoinToken(sessionId, readOnly);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PluginLogger } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type Meta = Record<string, unknown>;
|
||||
|
||||
export interface RemoteDesktopLogger {
|
||||
info: (message: string, meta?: Meta) => void;
|
||||
warn: (message: string, meta?: Meta) => void;
|
||||
error: (message: string, meta?: Meta) => void;
|
||||
}
|
||||
|
||||
function withMeta(message: string, meta?: Meta): string {
|
||||
return meta && Object.keys(meta).length > 0
|
||||
? `${message} ${JSON.stringify(meta)}`
|
||||
: message;
|
||||
}
|
||||
|
||||
/** ctx.log with the structured context the old guac logger carried. */
|
||||
export function createLogger(log: PluginLogger): RemoteDesktopLogger {
|
||||
return {
|
||||
info: (message, meta) => log.info(withMeta(message, meta)),
|
||||
warn: (message, meta) => log.warn(withMeta(message, meta)),
|
||||
error: (message, meta) => log.error(withMeta(message, meta)),
|
||||
};
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown, fallback = "Unknown error") {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === "string" && error) return error;
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import net from "net";
|
||||
|
||||
const RFB_BANNER_LENGTH = 12;
|
||||
const VNC_AUTH_SECURITY_TYPE = 2;
|
||||
const MACOS_RFB_BANNER = Buffer.from("RFB 003.889\n", "ascii");
|
||||
const STANDARD_RFB_BANNER = Buffer.from("RFB 003.008\n", "ascii");
|
||||
|
||||
export interface VncCompatibilityProxy {
|
||||
port: number;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export async function createMacosVncCompatibilityProxy({
|
||||
targetHost,
|
||||
targetPort,
|
||||
bindHost,
|
||||
}: {
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
bindHost: string;
|
||||
}): Promise<VncCompatibilityProxy> {
|
||||
const sockets = new Set<net.Socket>();
|
||||
const server = net.createServer((client) => {
|
||||
const upstream = net.createConnection(targetPort, targetHost);
|
||||
sockets.add(client);
|
||||
sockets.add(upstream);
|
||||
|
||||
const forget = (socket: net.Socket) => sockets.delete(socket);
|
||||
client.once("close", () => forget(client));
|
||||
upstream.once("close", () => forget(upstream));
|
||||
client.once("error", () => upstream.destroy());
|
||||
upstream.once("error", () => client.destroy());
|
||||
client.pipe(upstream);
|
||||
|
||||
let pending = Buffer.alloc(0);
|
||||
const forwardMacosSecurityTypes = (chunk: Buffer) => {
|
||||
pending = Buffer.concat([pending, chunk]);
|
||||
if (pending.length < 1) return;
|
||||
|
||||
const securityTypeCount = pending[0];
|
||||
if (securityTypeCount === 0) {
|
||||
upstream.off("data", forwardMacosSecurityTypes);
|
||||
client.write(pending);
|
||||
upstream.pipe(client);
|
||||
return;
|
||||
}
|
||||
if (pending.length < 1 + securityTypeCount) return;
|
||||
|
||||
upstream.off("data", forwardMacosSecurityTypes);
|
||||
const securityTypes = pending.subarray(1, 1 + securityTypeCount);
|
||||
if (securityTypes.includes(VNC_AUTH_SECURITY_TYPE)) {
|
||||
client.write(Buffer.from([1, VNC_AUTH_SECURITY_TYPE]));
|
||||
} else {
|
||||
client.write(pending.subarray(0, 1 + securityTypeCount));
|
||||
}
|
||||
client.write(pending.subarray(1 + securityTypeCount));
|
||||
upstream.pipe(client);
|
||||
};
|
||||
|
||||
const forwardServerBanner = (chunk: Buffer) => {
|
||||
pending = Buffer.concat([pending, chunk]);
|
||||
if (pending.length < RFB_BANNER_LENGTH) return;
|
||||
|
||||
upstream.off("data", forwardServerBanner);
|
||||
const banner = pending.subarray(0, RFB_BANNER_LENGTH);
|
||||
const remainder = pending.subarray(RFB_BANNER_LENGTH);
|
||||
if (banner.equals(MACOS_RFB_BANNER)) {
|
||||
client.write(STANDARD_RFB_BANNER);
|
||||
pending = Buffer.alloc(0);
|
||||
upstream.on("data", forwardMacosSecurityTypes);
|
||||
if (remainder.length > 0) forwardMacosSecurityTypes(remainder);
|
||||
} else {
|
||||
client.write(banner);
|
||||
client.write(remainder);
|
||||
upstream.pipe(client);
|
||||
}
|
||||
};
|
||||
upstream.on("data", forwardServerBanner);
|
||||
});
|
||||
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, bindHost, () => {
|
||||
server.off("error", reject);
|
||||
resolve((server.address() as net.AddressInfo).port);
|
||||
});
|
||||
});
|
||||
server.unref();
|
||||
|
||||
return {
|
||||
port,
|
||||
close: () => {
|
||||
server.close();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
interface RdpSettingsInput {
|
||||
port: number;
|
||||
domain?: string;
|
||||
security?: string;
|
||||
ignoreCert: boolean;
|
||||
guacConfig: Record<string, unknown>;
|
||||
guacdOverrides: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function buildRdpSettings({
|
||||
port,
|
||||
domain,
|
||||
security,
|
||||
ignoreCert,
|
||||
guacConfig,
|
||||
guacdOverrides,
|
||||
}: RdpSettingsInput): Record<string, unknown> {
|
||||
return {
|
||||
...guacConfig,
|
||||
port,
|
||||
domain,
|
||||
...(security === undefined ? {} : { security }),
|
||||
"ignore-cert": ignoreCert,
|
||||
...guacdOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRdpDomain(
|
||||
authType: string | null,
|
||||
promptedDomain: unknown,
|
||||
storedDomain: string,
|
||||
): string {
|
||||
return authType === "none" && typeof promptedDomain === "string"
|
||||
? promptedDomain
|
||||
: storedDomain;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Merges Termix's recording bookkeeping into a host's guacd settings.
|
||||
*
|
||||
* Location and filename are not the host's to choose: recordings are indexed by
|
||||
* them for playback, and the backend refuses to read anything outside its
|
||||
* recordings directory. What a recording *contains* is a host-level decision, so
|
||||
* those flags are only defaulted, never overwritten.
|
||||
*/
|
||||
export function withRecordingSettings(
|
||||
guacConfig: Record<string, unknown>,
|
||||
recordingPath: string,
|
||||
recordingName: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...guacConfig,
|
||||
"recording-path": recordingPath,
|
||||
"recording-name": recordingName,
|
||||
"create-recording-path": true,
|
||||
"recording-exclude-output": guacConfig["recording-exclude-output"] ?? false,
|
||||
"recording-include-keys": guacConfig["recording-include-keys"] ?? true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
import crypto from "crypto";
|
||||
import net from "net";
|
||||
import path from "path";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
isServerOwnedSetting,
|
||||
type GuacamoleTokenService,
|
||||
} from "./token-service.js";
|
||||
import type { GuacdOptions } from "./guacd-config.js";
|
||||
import type { RemoteSessions } from "./sessions.js";
|
||||
import type { RecordingsWriter } from "./guacamole-server.js";
|
||||
import { recordingsDir } from "./guacamole-server.js";
|
||||
import { withRecordingSettings } from "./recording-settings.js";
|
||||
import { withDriveSettings } from "./drive-settings.js";
|
||||
import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js";
|
||||
import { buildRdpSettings, resolveRdpDomain } from "./rdp-settings.js";
|
||||
import { createMacosVncCompatibilityProxy } from "./macos-vnc-proxy.js";
|
||||
import { openJumpTunnel } from "./jump-tunnel.js";
|
||||
import {
|
||||
DEFAULT_PORT,
|
||||
ENABLE_KEY,
|
||||
PORT_KEY,
|
||||
isRemoteProtocol,
|
||||
readHostSettings,
|
||||
readDisplayDefaults,
|
||||
type RemoteProtocol,
|
||||
} from "./host-settings.js";
|
||||
import { errorMessage, type RemoteDesktopLogger } from "./log.js";
|
||||
|
||||
export interface RouteDeps {
|
||||
ctx: PluginContext;
|
||||
log: RemoteDesktopLogger;
|
||||
tokens: GuacamoleTokenService;
|
||||
sessions: RemoteSessions;
|
||||
guacd: () => GuacdOptions;
|
||||
enabled: () => Promise<boolean>;
|
||||
recordings: () => RecordingsWriter | null;
|
||||
}
|
||||
|
||||
function actor(ctx: PluginContext, res: Response): string | null {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) res.status(401).json({ error: "Authentication required" });
|
||||
return userId ?? null;
|
||||
}
|
||||
|
||||
function probeGuacd({ host, port }: GuacdOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
socket.setTimeout(3000);
|
||||
const done = (ok: boolean) => {
|
||||
socket.destroy();
|
||||
resolve(ok);
|
||||
};
|
||||
socket.once("connect", () => done(true));
|
||||
socket.once("timeout", () => done(false));
|
||||
socket.once("error", () => done(false));
|
||||
socket.connect(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
/** guacd reads these settings as given; the editor stores "auto" for its default. */
|
||||
function cleanGuacConfig(raw: Record<string, unknown>): {
|
||||
config: Record<string, unknown>;
|
||||
guacdOverrides: { guacdHost?: string; guacdPort?: number };
|
||||
} {
|
||||
const config: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (value !== "auto") config[key] = value;
|
||||
}
|
||||
const guacdHost =
|
||||
typeof config["guacd-hostname"] === "string" && config["guacd-hostname"]
|
||||
? (config["guacd-hostname"] as string)
|
||||
: undefined;
|
||||
const guacdPort = config["guacd-port"]
|
||||
? parseInt(String(config["guacd-port"]), 10) || undefined
|
||||
: undefined;
|
||||
delete config["guacd-hostname"];
|
||||
delete config["guacd-port"];
|
||||
if (config.dpi != null) {
|
||||
const dpi = parseInt(String(config.dpi), 10);
|
||||
config.dpi = Number.isFinite(dpi) && dpi > 0 ? dpi : undefined;
|
||||
}
|
||||
return {
|
||||
config,
|
||||
guacdOverrides: {
|
||||
...(guacdHost ? { guacdHost } : {}),
|
||||
...(guacdPort ? { guacdPort } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registerRoutes(router: Router, deps: RouteDeps): void {
|
||||
const { ctx, log, tokens, sessions } = deps;
|
||||
|
||||
const refuseWhenOff = async (res: Response): Promise<boolean> => {
|
||||
if (await deps.enabled()) return false;
|
||||
res.status(403).json({ error: "Remote Desktop is turned off" });
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/remote-desktop/connection/{connectId}:
|
||||
* get:
|
||||
* summary: Look up guacd's id for a session
|
||||
* description: Returns the guacamole connection id for a connect request, once guacd has opened it. Only the session's owner gets an answer.
|
||||
* tags:
|
||||
* - Remote Desktop
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: connectId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The id, or null while guacd has not opened the session.
|
||||
* 401:
|
||||
* description: Not signed in.
|
||||
*/
|
||||
router.get("/connection/:connectId", (req: Request, res: Response) => {
|
||||
const userId = actor(ctx, res);
|
||||
if (!userId) return;
|
||||
const session = sessions.byConnect(String(req.params.connectId));
|
||||
res.json({
|
||||
guacamoleConnectionId:
|
||||
session?.ownerUserId === userId ? session.guacamoleConnectionId : null,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/remote-desktop/token:
|
||||
* post:
|
||||
* summary: Mint a connection token for an address
|
||||
* description: Creates an encrypted guacamole-lite token for connection details the caller supplies, used by Quick Connect. No saved host is involved.
|
||||
* tags:
|
||||
* - Remote Desktop
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - type
|
||||
* - hostname
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [rdp, vnc, telnet]
|
||||
* hostname:
|
||||
* type: string
|
||||
* port:
|
||||
* type: integer
|
||||
* username:
|
||||
* type: string
|
||||
* password:
|
||||
* type: string
|
||||
* domain:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The encrypted token.
|
||||
* 400:
|
||||
* description: Missing or invalid fields.
|
||||
* 403:
|
||||
* description: Remote Desktop is turned off.
|
||||
* 500:
|
||||
* description: Server error.
|
||||
*/
|
||||
router.post("/token", async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (await refuseWhenOff(res)) return;
|
||||
const { type, hostname, port, username, password, domain, ...raw } =
|
||||
(req.body ?? {}) as Record<string, unknown>;
|
||||
if (!type || !hostname) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing required fields: type and hostname" });
|
||||
}
|
||||
if (!isRemoteProtocol(type)) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid connection type. Must be rdp, vnc, or telnet",
|
||||
});
|
||||
}
|
||||
const options: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
// A quick connect names a host, never where guacd is or what paths
|
||||
// it writes to: those would let any user aim guacd anywhere.
|
||||
if (isServerOwnedSetting(key)) continue;
|
||||
if (value !== "auto") options[key] = value;
|
||||
}
|
||||
const host = String(hostname);
|
||||
const user = username ? String(username) : "";
|
||||
const pass = password ? String(password) : "";
|
||||
const targetPort = Number(port) || DEFAULT_PORT[type];
|
||||
const userId = ctx.currentActor();
|
||||
if (type === "rdp" && userId) {
|
||||
for (const [key, value] of Object.entries(
|
||||
await readDisplayDefaults(ctx, userId),
|
||||
)) {
|
||||
if (isServerOwnedSetting(key)) continue;
|
||||
if (options[key] === undefined) options[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
let token: string;
|
||||
if (type === "rdp") {
|
||||
token = tokens.createRdpToken(host, user, pass, {
|
||||
port: targetPort,
|
||||
domain: domain ? String(domain) : undefined,
|
||||
...options,
|
||||
});
|
||||
} else if (type === "vnc") {
|
||||
token = tokens.createVncToken(host, user || undefined, pass, {
|
||||
port: targetPort,
|
||||
...options,
|
||||
});
|
||||
} else {
|
||||
token = tokens.createTelnetToken(host, user, pass, {
|
||||
port: targetPort,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
res.json({ token });
|
||||
} catch (error) {
|
||||
log.error("Failed to generate a connection token", {
|
||||
operation: "guac_token_error",
|
||||
error: errorMessage(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to generate connection token" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/remote-desktop/connect-host/{hostId}:
|
||||
* post:
|
||||
* summary: Mint a connection token for a saved host
|
||||
* description: Resolves the host's RDP, VNC or Telnet login for the caller (a shared recipient gets only what was shared with them), opens a jump tunnel when the host has jump hosts, and returns an encrypted guacamole-lite token.
|
||||
* tags:
|
||||
* - Remote Desktop
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* protocol:
|
||||
* type: string
|
||||
* enum: [rdp, vnc, telnet]
|
||||
* promptedUsername:
|
||||
* type: string
|
||||
* promptedPassword:
|
||||
* type: string
|
||||
* promptedDomain:
|
||||
* type: string
|
||||
* tabInstanceId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The token and the id to look the session up by.
|
||||
* 400:
|
||||
* description: Invalid host id, or the protocol is off for this host.
|
||||
* 403:
|
||||
* description: Remote Desktop is turned off.
|
||||
* 404:
|
||||
* description: Host not found or no access.
|
||||
* 500:
|
||||
* description: The tunnel or the token could not be set up.
|
||||
*/
|
||||
router.post("/connect-host/:hostId", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = actor(ctx, res);
|
||||
if (!userId) return;
|
||||
if (await refuseWhenOff(res)) return;
|
||||
|
||||
const hostId = Number.parseInt(String(req.params.hostId), 10);
|
||||
if (!hostId) return res.status(400).json({ error: "Invalid host ID" });
|
||||
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const settings = await readHostSettings(ctx, hostId);
|
||||
const requested = body.protocol;
|
||||
const protocol: RemoteProtocol | undefined = isRemoteProtocol(requested)
|
||||
? requested
|
||||
: (["rdp", "vnc", "telnet"] as const).find(
|
||||
(candidate) => settings[ENABLE_KEY[candidate]],
|
||||
);
|
||||
if (!protocol) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Remote Desktop is not enabled for this host." });
|
||||
}
|
||||
|
||||
const target = await ctx.credentials.resolveHostProtocol(
|
||||
hostId,
|
||||
protocol,
|
||||
);
|
||||
if (!target) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
if (!settings[ENABLE_KEY[protocol]]) {
|
||||
return res.status(400).json({
|
||||
error: `${protocol.toUpperCase()} is not enabled for this host.`,
|
||||
});
|
||||
}
|
||||
|
||||
// The display settings sit under whatever the host's guacd settings say.
|
||||
const { config, guacdOverrides } = cleanGuacConfig({
|
||||
...(protocol === "rdp"
|
||||
? await readDisplayDefaults(ctx, userId, hostId)
|
||||
: {}),
|
||||
...settings.guacamoleConfig,
|
||||
});
|
||||
let guacConfig = config;
|
||||
|
||||
const promptForLogin =
|
||||
protocol === "rdp" && target.auth.authType === "none";
|
||||
const username = promptForLogin
|
||||
? String(body.promptedUsername || "")
|
||||
: target.auth.username;
|
||||
const password = promptForLogin
|
||||
? String(body.promptedPassword || "")
|
||||
: target.auth.password;
|
||||
const domain = resolveRdpDomain(
|
||||
target.auth.authType,
|
||||
body.promptedDomain,
|
||||
target.auth.fields.domain ?? "",
|
||||
);
|
||||
|
||||
let hostname = target.host.ip;
|
||||
let port =
|
||||
(settings[PORT_KEY[protocol]] as number) || DEFAULT_PORT[protocol];
|
||||
const termixConnectId = crypto.randomUUID();
|
||||
const cleanups: Array<() => void> = [];
|
||||
const jumpHosts = target.host.jumpHosts;
|
||||
const needsVncProxy = protocol === "vnc" && !username;
|
||||
|
||||
const endpoint =
|
||||
jumpHosts.length > 0 || needsVncProxy
|
||||
? resolveJumpTunnelEndpoint(
|
||||
guacdOverrides.guacdHost || deps.guacd().host,
|
||||
)
|
||||
: null;
|
||||
|
||||
try {
|
||||
if (jumpHosts.length > 0 && endpoint) {
|
||||
const tunnel = await openJumpTunnel(ctx, log, {
|
||||
jumpHosts,
|
||||
targetHost: hostname,
|
||||
targetPort: port,
|
||||
bindHost: endpoint.bindHost,
|
||||
connectId: termixConnectId,
|
||||
});
|
||||
cleanups.push(tunnel.close);
|
||||
hostname = endpoint.advertisedHost;
|
||||
port = tunnel.port;
|
||||
}
|
||||
|
||||
if (needsVncProxy && endpoint) {
|
||||
const proxy = await createMacosVncCompatibilityProxy({
|
||||
targetHost: hostname,
|
||||
targetPort: port,
|
||||
bindHost: endpoint.bindHost,
|
||||
});
|
||||
cleanups.push(proxy.close);
|
||||
hostname = endpoint.advertisedHost;
|
||||
port = proxy.port;
|
||||
}
|
||||
} catch (error) {
|
||||
for (const cleanup of cleanups) cleanup();
|
||||
log.error("Failed to reach the host through its jump hosts", {
|
||||
operation: "guac_ssh_tunnel_error",
|
||||
hostId,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to establish SSH tunnel to remote host" });
|
||||
}
|
||||
sessions.park(termixConnectId, cleanups);
|
||||
|
||||
const recordings = deps.recordings();
|
||||
const recordingEnabled =
|
||||
protocol !== "vnc" &&
|
||||
!!recordings &&
|
||||
(await recordings.enabledFor(hostId));
|
||||
const recordingName = `${crypto.randomUUID()}.guac`;
|
||||
const guacdRecordingPath =
|
||||
process.env.GUACD_RECORDING_PATH ||
|
||||
process.env.GUACD_RECORDING_BACKEND_PATH ||
|
||||
path.resolve(recordingsDir());
|
||||
const recording = recordingEnabled
|
||||
? {
|
||||
hostId,
|
||||
userId,
|
||||
protocol,
|
||||
path: recordingName,
|
||||
guacdPath: guacdRecordingPath,
|
||||
startedAt: new Date().toISOString(),
|
||||
}
|
||||
: undefined;
|
||||
if (recordingEnabled) {
|
||||
guacConfig = withRecordingSettings(
|
||||
guacConfig,
|
||||
guacdRecordingPath,
|
||||
recordingName,
|
||||
);
|
||||
}
|
||||
|
||||
const termixMeta = {
|
||||
termixConnectId,
|
||||
hostId,
|
||||
hostName: target.host.name || target.host.ip,
|
||||
ownerUserId: userId,
|
||||
protocol,
|
||||
tabInstanceId:
|
||||
typeof body.tabInstanceId === "string" ? body.tabInstanceId : null,
|
||||
};
|
||||
|
||||
let token: string;
|
||||
if (protocol === "rdp") {
|
||||
token = tokens.createRdpToken(
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
buildRdpSettings({
|
||||
port,
|
||||
domain,
|
||||
security: settings.rdpSecurity || undefined,
|
||||
ignoreCert: settings.rdpIgnoreCert,
|
||||
guacConfig: withDriveSettings(guacConfig, userId),
|
||||
guacdOverrides,
|
||||
}),
|
||||
recording,
|
||||
termixMeta,
|
||||
);
|
||||
} else if (protocol === "vnc") {
|
||||
token = tokens.createVncToken(
|
||||
hostname,
|
||||
username || undefined,
|
||||
password,
|
||||
{ port, ...guacConfig, ...guacdOverrides },
|
||||
recording,
|
||||
termixMeta,
|
||||
);
|
||||
} else {
|
||||
token = tokens.createTelnetToken(
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
{ port, ...guacConfig, ...guacdOverrides },
|
||||
recording,
|
||||
termixMeta,
|
||||
);
|
||||
}
|
||||
|
||||
await ctx.audit.record({
|
||||
action: `${protocol}_connect`,
|
||||
resourceType: "host",
|
||||
resourceId: String(hostId),
|
||||
resourceName: `${hostname}:${port}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ token, termixConnectId, guacamoleConnectionId: null });
|
||||
} catch (error) {
|
||||
log.error("Failed to generate a connection token for a host", {
|
||||
operation: "guac_host_token_error",
|
||||
error: errorMessage(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to generate connection token" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/remote-desktop/status:
|
||||
* get:
|
||||
* summary: Remote Desktop status
|
||||
* description: Whether Remote Desktop is turned on, and whether guacd answers at the configured address.
|
||||
* tags:
|
||||
* - Remote Desktop
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: probe
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Set to 0 to skip dialing guacd.
|
||||
* - in: query
|
||||
* name: hostId
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Saved host whose guacd settings should be checked; requires protocol and access to that host.
|
||||
* - in: query
|
||||
* name: protocol
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [rdp, vnc, telnet]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The status.
|
||||
* 500:
|
||||
* description: The check failed.
|
||||
*/
|
||||
router.get("/status", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const enabled = await deps.enabled();
|
||||
let guacd = deps.guacd();
|
||||
if (req.query.hostId !== undefined) {
|
||||
const userId = actor(ctx, res);
|
||||
if (!userId) return;
|
||||
const hostId = Number(req.query.hostId);
|
||||
const protocol = req.query.protocol;
|
||||
if (
|
||||
!Number.isSafeInteger(hostId) ||
|
||||
hostId <= 0 ||
|
||||
!isRemoteProtocol(protocol)
|
||||
) {
|
||||
return res.status(400).json({ error: "Invalid host ID or protocol" });
|
||||
}
|
||||
const target = await ctx.credentials.resolveHostProtocol(
|
||||
hostId,
|
||||
protocol,
|
||||
);
|
||||
if (!target) return res.status(404).json({ error: "Host not found" });
|
||||
const settings = await readHostSettings(ctx, hostId);
|
||||
if (!settings[ENABLE_KEY[protocol]]) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Protocol is not enabled for this host" });
|
||||
}
|
||||
const { guacdOverrides } = cleanGuacConfig({
|
||||
...(protocol === "rdp"
|
||||
? await readDisplayDefaults(ctx, userId, hostId)
|
||||
: {}),
|
||||
...settings.guacamoleConfig,
|
||||
});
|
||||
guacd = {
|
||||
host: guacdOverrides.guacdHost || guacd.host,
|
||||
port: guacdOverrides.guacdPort || guacd.port,
|
||||
};
|
||||
}
|
||||
const probe = enabled && req.query.probe !== "0";
|
||||
const reachable = probe ? await probeGuacd(guacd) : false;
|
||||
res.json({
|
||||
enabled,
|
||||
guacd: {
|
||||
host: guacd.host,
|
||||
port: guacd.port,
|
||||
status: reachable ? "connected" : "disconnected",
|
||||
},
|
||||
websocket: {
|
||||
path: "/plugin-ws/remote-desktop/display",
|
||||
status: "running",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Failed to check Remote Desktop status", {
|
||||
operation: "guac_status_error",
|
||||
error: errorMessage(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to check status" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/remote-desktop/native-rdp:
|
||||
* get:
|
||||
* summary: Whether the native RDP client can be opened
|
||||
* description: True only in the Windows desktop app.
|
||||
* tags:
|
||||
* - Remote Desktop
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Availability.
|
||||
* post:
|
||||
* summary: Open the native RDP client
|
||||
* description: Opens the operating system's own RDP client (mstsc) for an address. The password is never passed; the client asks for it. Windows desktop app only.
|
||||
* tags:
|
||||
* - Remote Desktop
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - host
|
||||
* properties:
|
||||
* host:
|
||||
* type: string
|
||||
* port:
|
||||
* type: integer
|
||||
* username:
|
||||
* type: string
|
||||
* domain:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Whether the client opened.
|
||||
* 400:
|
||||
* description: Missing host.
|
||||
* 404:
|
||||
* description: Not running in the desktop app.
|
||||
*/
|
||||
router.get("/native-rdp", (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
available: ctx.desktop.available() && process.platform === "win32",
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/native-rdp", async (req: Request, res: Response) => {
|
||||
if (!ctx.desktop.available()) {
|
||||
return res.status(404).json({ error: "Only in the desktop app" });
|
||||
}
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
if (typeof body.host !== "string" || !body.host) {
|
||||
return res.status(400).json({ error: "Missing host" });
|
||||
}
|
||||
try {
|
||||
const result = await ctx.desktop.launchExternalClient({
|
||||
protocol: "rdp",
|
||||
host: body.host,
|
||||
port: Number(body.port) || DEFAULT_PORT.rdp,
|
||||
username: typeof body.username === "string" ? body.username : undefined,
|
||||
domain: typeof body.domain === "string" ? body.domain : undefined,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.json({ success: false, error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
interface TokenLogger {
|
||||
warn: (message: string, meta?: Record<string, unknown>) => void;
|
||||
error: (message: string, meta?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export interface GuacamoleConnectionSettings {
|
||||
type?: "rdp" | "vnc" | "telnet";
|
||||
join?: string;
|
||||
readOnly?: boolean;
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
settings: {
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpi?: number;
|
||||
security?: string;
|
||||
"ignore-cert"?: boolean;
|
||||
"disable-auth"?: boolean;
|
||||
"enable-wallpaper"?: boolean;
|
||||
"enable-drive"?: boolean;
|
||||
"drive-path"?: string;
|
||||
"create-drive-path"?: boolean;
|
||||
"swap-red-blue"?: boolean;
|
||||
cursor?: string;
|
||||
"terminal-type"?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TermixGuacMeta {
|
||||
termixConnectId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
ownerUserId: string;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
tabInstanceId: string | null;
|
||||
}
|
||||
|
||||
export interface GuacamoleToken {
|
||||
connection: GuacamoleConnectionSettings;
|
||||
recording?: GuacamoleRecordingMetadata;
|
||||
termixMeta?: TermixGuacMeta;
|
||||
/** When the token stops opening displays, in ms since the epoch. */
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export interface GuacamoleRecordingMetadata {
|
||||
hostId: number;
|
||||
userId: string;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
path: string;
|
||||
/** Directory guacd was told to write into; differs from the backend's view
|
||||
* when guacd runs in its own container. */
|
||||
guacdPath?: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
const CIPHER = "aes-256-cbc";
|
||||
const KEY_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* How long a token opens a display for. Short, because a token for a saved
|
||||
* host carries its password and ends up in the display URL.
|
||||
*/
|
||||
export const TOKEN_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Settings the server decides, never the caller: where guacd is, and every
|
||||
* path guacd writes or reads on its own filesystem.
|
||||
*/
|
||||
export function isServerOwnedSetting(key: string): boolean {
|
||||
return (
|
||||
key.startsWith("guacd") ||
|
||||
key.endsWith("-path") ||
|
||||
key.startsWith("create-") ||
|
||||
key.startsWith("recording-") ||
|
||||
key.startsWith("typescript-")
|
||||
);
|
||||
}
|
||||
|
||||
const silentLogger: TokenLogger = { warn: () => {}, error: () => {} };
|
||||
|
||||
/**
|
||||
* Encrypts the connection tokens guacamole-lite decrypts. Built in activate;
|
||||
* the key is derived from the environment, so a restart keeps it.
|
||||
*/
|
||||
export class GuacamoleTokenService {
|
||||
private encryptionKey: Buffer;
|
||||
private macKey: Buffer;
|
||||
|
||||
constructor(
|
||||
private readonly log: TokenLogger = silentLogger,
|
||||
private readonly now: () => number = Date.now,
|
||||
) {
|
||||
this.encryptionKey = this.initializeKey();
|
||||
this.macKey = crypto
|
||||
.createHmac("sha256", this.encryptionKey)
|
||||
.update("termix-guacamole-token-mac")
|
||||
.digest();
|
||||
}
|
||||
|
||||
private initializeKey(): Buffer {
|
||||
const existingKey = process.env.GUACAMOLE_ENCRYPTION_KEY;
|
||||
if (existingKey) {
|
||||
if (existingKey.length === 64 && /^[0-9a-fA-F]+$/.test(existingKey)) {
|
||||
return Buffer.from(existingKey, "hex");
|
||||
}
|
||||
if (existingKey.length === KEY_LENGTH) {
|
||||
return Buffer.from(existingKey, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
// A token lives for seconds between issue and connect, so a fresh key
|
||||
// per boot is enough and nothing is derived from core's own secrets.
|
||||
return crypto.randomBytes(KEY_LENGTH);
|
||||
}
|
||||
|
||||
getEncryptionKey(): Buffer {
|
||||
return this.encryptionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* guacamole-lite only knows AES-CBC, which does not notice tampering, so
|
||||
* the ciphertext also carries an HMAC that verifyToken checks before
|
||||
* guacamole-lite ever sees the token.
|
||||
*/
|
||||
encryptToken(tokenObject: GuacamoleToken): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(CIPHER, this.encryptionKey, iv);
|
||||
|
||||
let encrypted = cipher.update(
|
||||
JSON.stringify({ ...tokenObject, exp: this.now() + TOKEN_TTL_MS }),
|
||||
"utf8",
|
||||
"base64",
|
||||
);
|
||||
encrypted += cipher.final("base64");
|
||||
|
||||
const data = {
|
||||
iv: iv.toString("base64"),
|
||||
value: encrypted,
|
||||
mac: this.mac(iv.toString("base64"), encrypted),
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(data)).toString("base64");
|
||||
}
|
||||
|
||||
private mac(iv: string, value: string): string {
|
||||
return crypto
|
||||
.createHmac("sha256", this.macKey)
|
||||
.update(`${iv}.${value}`)
|
||||
.digest("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* The token if it is one this server minted, untouched and not expired,
|
||||
* else null. The gate in front of guacamole-lite.
|
||||
*/
|
||||
verifyToken(token: string): GuacamoleToken | null {
|
||||
let data: { iv?: unknown; value?: unknown; mac?: unknown };
|
||||
try {
|
||||
data = JSON.parse(Buffer.from(token, "base64").toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof data?.iv !== "string" ||
|
||||
typeof data.value !== "string" ||
|
||||
typeof data.mac !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const expected = Buffer.from(this.mac(data.iv, data.value), "base64");
|
||||
const given = Buffer.from(data.mac, "base64");
|
||||
if (
|
||||
expected.length !== given.length ||
|
||||
!crypto.timingSafeEqual(expected, given)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const decoded = this.decryptToken(token);
|
||||
if (!decoded || typeof decoded.exp !== "number") return null;
|
||||
return decoded.exp > this.now() ? decoded : null;
|
||||
}
|
||||
|
||||
decryptToken(token: string): GuacamoleToken | null {
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(token, "base64").toString("utf8"));
|
||||
const iv = Buffer.from(data.iv, "base64");
|
||||
const decipher = crypto.createDecipheriv(CIPHER, this.encryptionKey, iv);
|
||||
|
||||
let decrypted = decipher.update(data.value, "base64", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return JSON.parse(decrypted) as GuacamoleToken;
|
||||
} catch (error) {
|
||||
this.log.error("Failed to decrypt guacamole token", {
|
||||
operation: "guac_token_decrypt_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
createRdpToken(
|
||||
hostname: string,
|
||||
username: string,
|
||||
password: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> & {
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
termixMeta?: TermixGuacMeta,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "rdp",
|
||||
...(guacdHost ? { guacdHost } : {}),
|
||||
...(guacdPort ? { guacdPort } : {}),
|
||||
settings: {
|
||||
hostname,
|
||||
...(username ? { username } : {}),
|
||||
...(password ? { password } : {}),
|
||||
port: 3389,
|
||||
"ignore-cert": true,
|
||||
...(!username && !password ? { "disable-auth": true } : {}),
|
||||
...settingsOptions,
|
||||
},
|
||||
},
|
||||
recording,
|
||||
termixMeta,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
createVncToken(
|
||||
hostname: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> & {
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
termixMeta?: TermixGuacMeta,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "vnc",
|
||||
...(guacdHost ? { guacdHost } : {}),
|
||||
...(guacdPort ? { guacdPort } : {}),
|
||||
settings: {
|
||||
hostname,
|
||||
...(username ? { username } : {}),
|
||||
password,
|
||||
port: 5900,
|
||||
...settingsOptions,
|
||||
},
|
||||
},
|
||||
recording,
|
||||
termixMeta,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
createTelnetToken(
|
||||
hostname: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> & {
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
termixMeta?: TermixGuacMeta,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "telnet",
|
||||
...(guacdHost ? { guacdHost } : {}),
|
||||
...(guacdPort ? { guacdPort } : {}),
|
||||
settings: {
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
port: 23,
|
||||
...settingsOptions,
|
||||
},
|
||||
},
|
||||
recording,
|
||||
termixMeta,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
// join tokens never carry recording params - only the primary connection's
|
||||
// token should write recording-path/recording-name to guacd.
|
||||
createJoinToken(guacamoleConnectionId: string, readOnly: boolean): string {
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
join: guacamoleConnectionId,
|
||||
readOnly,
|
||||
settings: {},
|
||||
},
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
import { watchGuacamoleConnectionId } from "./guacamole-session-id";
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import type Guacamole from "guacamole-common-js";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
GuacamoleDisplay,
|
||||
type GuacamoleDisplayHandle,
|
||||
type GuacamoleTouchMode,
|
||||
} from "./GuacamoleDisplay.tsx";
|
||||
import { getGuacamoleTokenFromHost, getGuacdStatus } from "./guacamole-api";
|
||||
import { readConfiguredDimension } from "./guacamole-display-size.ts";
|
||||
import { getGuacamoleToken } from "./guacamole-api";
|
||||
import {
|
||||
useHost,
|
||||
useTranslation,
|
||||
useConnectionRetry,
|
||||
logActivity,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Button,
|
||||
ConnectionLogProvider,
|
||||
ConnectionScreen,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
PasswordInput,
|
||||
isElectron,
|
||||
linkedServerUrl,
|
||||
resolveConnectionOrigin,
|
||||
useConnectionLog,
|
||||
type ConnectionOrigin,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { GuacamoleConfig } from "./guacamole-config";
|
||||
import {
|
||||
errorMessage,
|
||||
hostRemoteOptions,
|
||||
type Protocol,
|
||||
rdpDomain,
|
||||
type RemoteHostLogin,
|
||||
} from "./host-remote";
|
||||
import { GuacamoleToolbar } from "./GuacamoleToolbar.tsx";
|
||||
import { GuacamoleFileBrowser } from "./GuacamoleFileBrowser.tsx";
|
||||
import { describeUploadError } from "./guacamole-filesystem.ts";
|
||||
import { canUploadToRdpDrive } from "./guacamole-file-drop.ts";
|
||||
import { needsRdpCredentialPrompt } from "./rdp-credential-prompt";
|
||||
|
||||
interface GuacamoleAppProps {
|
||||
hostId?: string;
|
||||
tabId?: string;
|
||||
protocol?: "rdp" | "vnc" | "telnet";
|
||||
isVisible?: boolean;
|
||||
/** A quick-connect host: never saved, so the token is minted from its fields. */
|
||||
quickConnectHost?: GuacamoleQuickHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a session needs from its host. A saved host brings its options; a
|
||||
* quick-connect host has no row, so it also carries its address and login.
|
||||
*/
|
||||
export interface GuacamoleHostConfig {
|
||||
name?: string;
|
||||
ip: string;
|
||||
connectionType?: Protocol;
|
||||
domain?: string;
|
||||
guacamoleConfig?: GuacamoleConfig;
|
||||
rdpAuthType?: string;
|
||||
authOverrides?: RemoteHostLogin["authOverrides"];
|
||||
syncId?: string | null;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
showToolbar?: boolean;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export type GuacamoleQuickHost = GuacamoleHostConfig;
|
||||
|
||||
function savedHostConfig(
|
||||
host: RemoteHostLogin,
|
||||
protocol: Protocol | undefined,
|
||||
): GuacamoleHostConfig {
|
||||
const options = hostRemoteOptions(host);
|
||||
return {
|
||||
name: host.name ?? undefined,
|
||||
ip: host.ip ?? "",
|
||||
connectionType:
|
||||
protocol ??
|
||||
(options.enableRdp ? "rdp" : options.enableVnc ? "vnc" : "telnet"),
|
||||
domain: rdpDomain(host),
|
||||
guacamoleConfig: options.guacamoleConfig,
|
||||
rdpAuthType: host.protocolAuth?.rdp?.authType,
|
||||
authOverrides: host.authOverrides,
|
||||
syncId: host.syncId,
|
||||
connectionOrigin: host.connectionOrigin,
|
||||
showToolbar: options.enableToolbar,
|
||||
};
|
||||
}
|
||||
|
||||
export interface GuacamoleAppHandle {
|
||||
disconnect: () => void;
|
||||
isConnected: () => boolean;
|
||||
/** The tab bar's refresh. The shell calls `refresh` on every tab switch. */
|
||||
reconnect: () => void;
|
||||
}
|
||||
|
||||
const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
|
||||
function GuacamoleApp(
|
||||
{ hostId, tabId, protocol, isVisible = true, quickConnectHost },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const savedHost = useHost(quickConnectHost ? undefined : hostId);
|
||||
const hostConfig: GuacamoleHostConfig | null = quickConnectHost
|
||||
? quickConnectHost
|
||||
: savedHost
|
||||
? savedHostConfig(savedHost as RemoteHostLogin, protocol)
|
||||
: null;
|
||||
// The host list loads with the shell; a tab restored before it arrives
|
||||
// waits for it instead of reporting the host missing.
|
||||
const [waited, setWaited] = useState(false);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setWaited(true), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
const loading = !hostConfig && !!hostId && !waited;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<ConnectionScreen status="connecting" message={t("common.loading")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig || !hostId) {
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("remoteDesktop.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConnectionLogProvider>
|
||||
<GuacamoleAppInner
|
||||
hostId={quickConnectHost ? 0 : parseInt(hostId, 10)}
|
||||
hostConfig={hostConfig}
|
||||
hostName={hostConfig.name || hostConfig.ip || String(hostId ?? "")}
|
||||
tabId={tabId}
|
||||
protocol={protocol}
|
||||
isVisible={isVisible}
|
||||
ref={ref}
|
||||
/>
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface GuacamoleAppInnerProps {
|
||||
hostId: number;
|
||||
hostConfig: GuacamoleHostConfig;
|
||||
hostName: string;
|
||||
tabId?: string;
|
||||
protocol?: "rdp" | "vnc" | "telnet";
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
const GuacamoleAppInner = React.forwardRef<
|
||||
GuacamoleAppHandle,
|
||||
GuacamoleAppInnerProps
|
||||
>(function GuacamoleAppInner(
|
||||
{ hostId, hostConfig, hostName, tabId, protocol, isVisible },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { addLog, clearLogs } = useConnectionLog();
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [guacamoleConnectionId, setGuacamoleConnectionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [sessionLookup, setSessionLookup] = useState<{
|
||||
id: string;
|
||||
origin: ConnectionOrigin;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [isDisplayReady, setIsDisplayReady] = useState(false);
|
||||
const [touchMode, setTouchMode] = useState<GuacamoleTouchMode | null>(() =>
|
||||
typeof window !== "undefined" &&
|
||||
(navigator.maxTouchPoints > 0 || "ontouchstart" in window)
|
||||
? "touchscreen"
|
||||
: null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isDisplayReady || !sessionLookup) return;
|
||||
return watchGuacamoleConnectionId(
|
||||
sessionLookup.id,
|
||||
sessionLookup.origin,
|
||||
setGuacamoleConnectionId,
|
||||
);
|
||||
}, [isDisplayReady, sessionLookup]);
|
||||
const displayRef = useRef<GuacamoleDisplayHandle>(null);
|
||||
const [displayZoom, setDisplayZoom] = useState(1);
|
||||
const [filesystem, setFilesystem] = useState<Guacamole.Object | null>(null);
|
||||
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
||||
const [toolbarHidden, setToolbarHidden] = useState(false);
|
||||
const [pendingUploads, setPendingUploads] = useState<File[]>([]);
|
||||
|
||||
const guacConfig = hostConfig.guacamoleConfig ?? {};
|
||||
const allowUpload = guacConfig.disableUpload !== true;
|
||||
const allowDownload = guacConfig.disableDownload !== true;
|
||||
|
||||
// Prefer the browsable filesystem's current directory. guacd may expose the
|
||||
// RDP drive only through the connection-level file stream, in which case the
|
||||
// standard direct upload still lands in the redirected drive.
|
||||
const handleDropFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
if (filesystem) {
|
||||
setPendingUploads(files);
|
||||
setFileBrowserOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
for (const file of files) {
|
||||
try {
|
||||
const display = displayRef.current;
|
||||
if (!display) throw new Error("RDP session is not ready");
|
||||
await display.uploadFile(file);
|
||||
toast.success(
|
||||
t("remoteDesktop.files.uploaded", { name: file.name }),
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
describeUploadError(error, (key) =>
|
||||
t(`remoteDesktop.files.${key}`, { name: file.name }),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
},
|
||||
[filesystem, t],
|
||||
);
|
||||
|
||||
const handleDropUnavailable = useCallback(() => {
|
||||
toast.error(
|
||||
t(
|
||||
allowUpload
|
||||
? "remoteDesktop.files.driveUnavailable"
|
||||
: "remoteDesktop.files.uploadDisabled",
|
||||
),
|
||||
);
|
||||
}, [allowUpload, t]);
|
||||
|
||||
const resolvedProtocolForConnect = (protocol ??
|
||||
hostConfig.connectionType ??
|
||||
"rdp") as "rdp" | "vnc" | "telnet";
|
||||
const needsCredentialPrompt = needsRdpCredentialPrompt({
|
||||
protocol: resolvedProtocolForConnect,
|
||||
rdpAuthType: hostConfig.rdpAuthType,
|
||||
authOverrides: hostConfig.authOverrides,
|
||||
});
|
||||
|
||||
const [promptedCredentials, setPromptedCredentials] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
domain: string;
|
||||
} | null>(null);
|
||||
const [promptOpen, setPromptOpen] = useState(needsCredentialPrompt);
|
||||
const [promptUsername, setPromptUsername] = useState("");
|
||||
const [promptPassword, setPromptPassword] = useState("");
|
||||
const [promptDomain, setPromptDomain] = useState(hostConfig.domain ?? "");
|
||||
|
||||
// Assigned below, once handleReconnect exists; the shell's tab refresh calls it.
|
||||
const reconnectRef = useRef<() => void>(() => {});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: () => displayRef.current?.disconnect(),
|
||||
isConnected: () => displayRef.current?.isConnected() === true,
|
||||
reconnect: () => reconnectRef.current(),
|
||||
}));
|
||||
|
||||
const fetchToken = useCallback(async (): Promise<void> => {
|
||||
setToken(null);
|
||||
setIsDisplayReady(false);
|
||||
setSessionLookup(null);
|
||||
setGuacamoleConnectionId(null);
|
||||
setError(null);
|
||||
|
||||
// Outside Electron there is only one backend, so the origin is moot and
|
||||
// the API layer ignores it; inside, it decides which backend mints the
|
||||
// token and therefore has to match the one the session will run on.
|
||||
let resolvedOrigin: ConnectionOrigin = "local";
|
||||
|
||||
if (isElectron()) {
|
||||
resolvedOrigin = await resolveConnectionOrigin(
|
||||
{ connectionOrigin: hostConfig.connectionOrigin },
|
||||
{ defaultRemote: true },
|
||||
);
|
||||
if (resolvedOrigin === "remote") {
|
||||
if (!(await linkedServerUrl())) {
|
||||
throw new Error(t("errors.remoteServerRequired"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "guac_guacd",
|
||||
message: t("remoteDesktop.checkingGuacd"),
|
||||
});
|
||||
const status = await getGuacdStatus(resolvedOrigin, {
|
||||
hostId,
|
||||
protocol: resolvedProtocolForConnect,
|
||||
syncId: hostConfig.syncId,
|
||||
});
|
||||
if (status.enabled === false) {
|
||||
throw new Error(t("remoteDesktop.disabled"));
|
||||
}
|
||||
if (status.guacd.status !== "connected") {
|
||||
throw new Error(t("remoteDesktop.guacdUnavailable"));
|
||||
}
|
||||
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "guac_token",
|
||||
message: t("remoteDesktop.requestingToken", {
|
||||
type: resolvedProtocolForConnect.toUpperCase(),
|
||||
}),
|
||||
});
|
||||
// hostId 0 is a quick-connect host: nothing to look up, mint the token
|
||||
// straight from what the user typed. It cannot be shared or logged as
|
||||
// host activity because there is no host row.
|
||||
const result =
|
||||
hostId === 0
|
||||
? await getGuacamoleToken(
|
||||
{
|
||||
protocol: resolvedProtocolForConnect,
|
||||
hostname: hostConfig.ip,
|
||||
port: hostConfig.port,
|
||||
username: hostConfig.username,
|
||||
password: hostConfig.password,
|
||||
domain: hostConfig.domain,
|
||||
ignoreCert: true,
|
||||
guacamoleConfig: hostConfig.guacamoleConfig,
|
||||
},
|
||||
resolvedOrigin,
|
||||
)
|
||||
: await getGuacamoleTokenFromHost(
|
||||
hostId,
|
||||
resolvedOrigin,
|
||||
protocol,
|
||||
promptedCredentials ?? undefined,
|
||||
hostConfig.syncId,
|
||||
tabId,
|
||||
);
|
||||
if (result) {
|
||||
setSessionLookup(
|
||||
result.termixConnectId
|
||||
? { id: result.termixConnectId, origin: resolvedOrigin }
|
||||
: null,
|
||||
);
|
||||
setToken(result.token);
|
||||
setGuacamoleConnectionId(result.guacamoleConnectionId ?? null);
|
||||
if (hostId !== 0) {
|
||||
logActivity(resolvedProtocolForConnect, hostId, hostName).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
hostId,
|
||||
hostName,
|
||||
protocol,
|
||||
promptedCredentials,
|
||||
resolvedProtocolForConnect,
|
||||
hostConfig,
|
||||
tabId,
|
||||
addLog,
|
||||
t,
|
||||
]);
|
||||
|
||||
const tokenRetry = useConnectionRetry({
|
||||
connect: async () => {
|
||||
try {
|
||||
await fetchToken();
|
||||
tokenRetryRef.current.markConnected();
|
||||
} catch (err: unknown) {
|
||||
const message = errorMessage(err, t("remoteDesktop.failedToConnect"));
|
||||
setError(message || t("remoteDesktop.failedToConnect"));
|
||||
addLog({ type: "error", stage: "error", message });
|
||||
tokenRetryRef.current.markFailed();
|
||||
}
|
||||
},
|
||||
enabled: !needsCredentialPrompt || !!promptedCredentials,
|
||||
autoStart: false,
|
||||
});
|
||||
const tokenRetryRef = useRef(tokenRetry);
|
||||
tokenRetryRef.current = tokenRetry;
|
||||
|
||||
useEffect(() => {
|
||||
if (needsCredentialPrompt && !promptedCredentials) {
|
||||
setPromptOpen(true);
|
||||
return;
|
||||
}
|
||||
clearLogs();
|
||||
tokenRetryRef.current.reset();
|
||||
tokenRetryRef.current.retryNow();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostId, protocol, needsCredentialPrompt, promptedCredentials]);
|
||||
|
||||
const handleReconnect = useCallback(() => {
|
||||
setConnectionError(null);
|
||||
setError(null);
|
||||
setToken(null);
|
||||
setIsDisplayReady(false);
|
||||
if (needsCredentialPrompt) {
|
||||
setPromptedCredentials(null);
|
||||
setPromptUsername("");
|
||||
setPromptPassword("");
|
||||
setPromptDomain(hostConfig.domain ?? "");
|
||||
setPromptOpen(true);
|
||||
return;
|
||||
}
|
||||
clearLogs();
|
||||
tokenRetryRef.current.reset();
|
||||
tokenRetryRef.current.retryNow();
|
||||
}, [needsCredentialPrompt, hostConfig.domain, clearLogs]);
|
||||
|
||||
reconnectRef.current = handleReconnect;
|
||||
|
||||
if (promptOpen) {
|
||||
return (
|
||||
<Dialog
|
||||
open={promptOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPromptOpen(false);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("remoteDesktop.credentialPromptTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("remoteDesktop.credentialPromptDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4 mt-1"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setPromptedCredentials({
|
||||
username: promptUsername,
|
||||
password: promptPassword,
|
||||
domain: promptDomain,
|
||||
});
|
||||
setPromptOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.guac.username")}
|
||||
</label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Administrator"
|
||||
value={promptUsername}
|
||||
onChange={(e) => setPromptUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.guac.domain")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="WORKGROUP"
|
||||
value={promptDomain}
|
||||
onChange={(e) => setPromptDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.guac.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={promptPassword}
|
||||
onChange={(e) => setPromptPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button type="submit" variant="outline">
|
||||
{t("remoteDesktop.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !token) {
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<ConnectionScreen
|
||||
status={error ? tokenRetry.status : "connecting"}
|
||||
message={t("remoteDesktop.connecting", {
|
||||
type: (
|
||||
protocol ||
|
||||
hostConfig.connectionType ||
|
||||
"remote"
|
||||
).toUpperCase(),
|
||||
})}
|
||||
attempt={tokenRetry.attempt}
|
||||
maxAttempts={tokenRetry.maxAttempts}
|
||||
nextRetryInMs={tokenRetry.nextRetryInMs}
|
||||
onManualRetry={handleReconnect}
|
||||
retryLabel={t("remoteDesktop.retry")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedProtocol = resolvedProtocolForConnect;
|
||||
const configuredDpi = readConfiguredDimension(guacConfig.dpi);
|
||||
const configuredWidth = readConfiguredDimension(guacConfig.width);
|
||||
const configuredHeight = readConfiguredDimension(guacConfig.height);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
{(!isDisplayReady || connectionError) && (
|
||||
<ConnectionScreen
|
||||
status={connectionError ? "disconnected" : "connecting"}
|
||||
message={t("remoteDesktop.connecting", {
|
||||
type: resolvedProtocol.toUpperCase(),
|
||||
})}
|
||||
onManualRetry={handleReconnect}
|
||||
retryLabel={t("remoteDesktop.reconnect")}
|
||||
className="z-50"
|
||||
/>
|
||||
)}
|
||||
<GuacamoleDisplay
|
||||
key={`${token}-${touchMode}`}
|
||||
ref={displayRef}
|
||||
connectionConfig={{
|
||||
token,
|
||||
protocol: resolvedProtocol,
|
||||
type: resolvedProtocol,
|
||||
connectionOrigin: hostConfig.connectionOrigin,
|
||||
width: configuredWidth,
|
||||
height: configuredHeight,
|
||||
dpi: configuredDpi,
|
||||
}}
|
||||
isVisible={isVisible}
|
||||
touchMode={touchMode}
|
||||
allowUpload={canUploadToRdpDrive(
|
||||
allowUpload,
|
||||
guacConfig.enableDrive === true,
|
||||
filesystem !== null,
|
||||
)}
|
||||
onConnect={() => setIsDisplayReady(true)}
|
||||
onError={(err) => {
|
||||
setConnectionError(err);
|
||||
addLog({ type: "error", stage: "error", message: err });
|
||||
}}
|
||||
onStageChange={(stage) => {
|
||||
const type = resolvedProtocol.toUpperCase();
|
||||
switch (stage) {
|
||||
case "guac_connecting":
|
||||
addLog({
|
||||
type: "info",
|
||||
stage,
|
||||
message: t("remoteDesktop.openingSession", { type }),
|
||||
});
|
||||
break;
|
||||
case "guac_handshake":
|
||||
addLog({
|
||||
type: "info",
|
||||
stage,
|
||||
message: t("remoteDesktop.negotiating", { type }),
|
||||
});
|
||||
break;
|
||||
case "guac_ready":
|
||||
addLog({
|
||||
type: "success",
|
||||
stage,
|
||||
message: t("remoteDesktop.sessionReady", { type }),
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}}
|
||||
onZoomChange={setDisplayZoom}
|
||||
onFilesystem={setFilesystem}
|
||||
onDropFiles={handleDropFiles}
|
||||
onDropUnavailable={handleDropUnavailable}
|
||||
/>
|
||||
{filesystem && fileBrowserOpen && (
|
||||
<GuacamoleFileBrowser
|
||||
filesystem={filesystem}
|
||||
allowUpload={allowUpload}
|
||||
allowDownload={allowDownload}
|
||||
pendingUploads={pendingUploads}
|
||||
onPendingUploadsHandled={() => setPendingUploads([])}
|
||||
onClose={() => setFileBrowserOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{hostConfig.showToolbar !== false && !toolbarHidden && (
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol={resolvedProtocol}
|
||||
touchMode={touchMode}
|
||||
hasFilesystem={filesystem !== null}
|
||||
fileBrowserOpen={fileBrowserOpen}
|
||||
onToggleFileBrowser={() => setFileBrowserOpen((open) => !open)}
|
||||
onTouchModeChange={setTouchMode}
|
||||
zoom={displayZoom}
|
||||
onHide={() => setToolbarHidden(true)}
|
||||
slotContext={
|
||||
typeof hostId === "number"
|
||||
? {
|
||||
hostId,
|
||||
sessionId: guacamoleConnectionId,
|
||||
protocol: resolvedProtocol,
|
||||
tabInstanceId: tabId,
|
||||
origin:
|
||||
sessionLookup?.origin === "remote" ? "remote" : "local",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default GuacamoleApp;
|
||||
@@ -0,0 +1,924 @@
|
||||
import type React from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { Upload } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { getGuacamoleToken } from "./guacamole-api";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
|
||||
import {
|
||||
buildOriginWsUrl,
|
||||
getBasePath,
|
||||
isElectron,
|
||||
resolveConnectionOrigin,
|
||||
type ConnectionOrigin,
|
||||
type ConnectionStage,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { errorMessage } from "./host-remote";
|
||||
import { guacStateToStage } from "./guacamole-state.ts";
|
||||
import { isPasteShortcut, pasteTextToRemote } from "./guacamole-clipboard.ts";
|
||||
import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts";
|
||||
import { bindPointerInput } from "./guacamole-pointer.ts";
|
||||
import {
|
||||
getFileDropDisposition,
|
||||
hasDraggedFiles,
|
||||
} from "./guacamole-file-drop.ts";
|
||||
import {
|
||||
uploadFileToClient,
|
||||
type GuacamoleFileStreamClient,
|
||||
} from "./guacamole-filesystem.ts";
|
||||
import { clampGuacamoleZoom, stepGuacamoleZoom } from "./guacamole-zoom.ts";
|
||||
|
||||
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
|
||||
|
||||
export interface GuacamoleConnectionConfig {
|
||||
token?: string;
|
||||
protocol?: GuacamoleConnectionType;
|
||||
type?: GuacamoleConnectionType;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpi?: number;
|
||||
/**
|
||||
* Which backend serves this session. Supplied by the owning app so the
|
||||
* websocket dials the same backend that minted the token; omitted by
|
||||
* shared/collab views, which follow the default for the connection type.
|
||||
*/
|
||||
connectionOrigin?: ConnectionOrigin | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface GuacamoleDisplayHandle {
|
||||
disconnect: () => void;
|
||||
isConnected: () => boolean;
|
||||
sendKey: (keysym: number, pressed: boolean) => void;
|
||||
sendMouse: (x: number, y: number, buttonMask: number) => void;
|
||||
setClipboard: (data: string) => void;
|
||||
getFilesystem: () => Guacamole.Object | null;
|
||||
uploadFile: (file: File) => Promise<void>;
|
||||
zoomIn: () => number;
|
||||
zoomOut: () => number;
|
||||
resetZoom: () => number;
|
||||
}
|
||||
|
||||
export type GuacamoleTouchMode = "touchscreen" | "touchpad";
|
||||
|
||||
interface GuacamoleDisplayProps {
|
||||
connectionConfig: GuacamoleConnectionConfig;
|
||||
isVisible: boolean;
|
||||
touchMode?: GuacamoleTouchMode | null;
|
||||
allowUpload?: boolean;
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
onFilesystem?: (filesystem: Guacamole.Object | null) => void;
|
||||
onDropFiles?: (files: File[]) => void;
|
||||
onDropUnavailable?: () => void;
|
||||
onStageChange?: (stage: ConnectionStage) => void;
|
||||
onZoomChange?: (zoom: number) => void;
|
||||
}
|
||||
|
||||
export const GuacamoleDisplay = forwardRef<
|
||||
GuacamoleDisplayHandle,
|
||||
GuacamoleDisplayProps
|
||||
>(function GuacamoleDisplay(
|
||||
{
|
||||
connectionConfig,
|
||||
isVisible,
|
||||
touchMode,
|
||||
allowUpload = false,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onError,
|
||||
onFilesystem,
|
||||
onDropFiles,
|
||||
onDropUnavailable,
|
||||
onStageChange,
|
||||
onZoomChange,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
// The host config pins the session resolution; without it the display follows
|
||||
// the container.
|
||||
const hasConfiguredSize =
|
||||
connectionConfig.width != null && connectionConfig.height != null;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const displayRef = useRef<HTMLDivElement>(null);
|
||||
const displayElementRef = useRef<HTMLElement | null>(null);
|
||||
const clientRef = useRef<Guacamole.Client | null>(null);
|
||||
const filesystemRef = useRef<Guacamole.Object | null>(null);
|
||||
// Held in a ref so tearing down the client can report the loss without
|
||||
// rebuilding the connect callback whenever the parent re-renders.
|
||||
const onFilesystemRef = useRef(onFilesystem);
|
||||
onFilesystemRef.current = onFilesystem;
|
||||
const onStageChangeRef = useRef(onStageChange);
|
||||
onStageChangeRef.current = onStageChange;
|
||||
const keyboardRef = useRef<Guacamole.Keyboard | null>(null);
|
||||
const unbindPointerRef = useRef<(() => void) | null>(null);
|
||||
const scaleRef = useRef<number>(1);
|
||||
const fitScaleRef = useRef<number>(1);
|
||||
const zoomRef = useRef<number>(1);
|
||||
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasKeyboardFocusRef = useRef(false);
|
||||
const windowFocusedRef = useRef(
|
||||
typeof document === "undefined" ? true : document.hasFocus(),
|
||||
);
|
||||
const hasInitiatedRef = useRef(false);
|
||||
const isMountedRef = useRef(false);
|
||||
const isConnectingRef = useRef(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
// RDP's guacd backend (FreeRDP) can report the Guacamole-level CONNECTED
|
||||
// state from a single early sync before the actual RDP handshake with the
|
||||
// remote host has finished negotiating - a handshake that can then still
|
||||
// fail up to ~30s later. Waiting for a couple of real frame syncs before
|
||||
// announcing the connection avoids a black screen with no connecting UI
|
||||
// during that window; VNC/telnet fail before ever reaching CONNECTED, so
|
||||
// they are unaffected.
|
||||
const syncCountRef = useRef(0);
|
||||
|
||||
const disconnectClient = useCallback(() => {
|
||||
unbindPointerRef.current?.();
|
||||
unbindPointerRef.current = null;
|
||||
const client = clientRef.current;
|
||||
clientRef.current = null;
|
||||
isConnectingRef.current = false;
|
||||
if (filesystemRef.current) {
|
||||
filesystemRef.current = null;
|
||||
onFilesystemRef.current?.(null);
|
||||
}
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
client.disconnect();
|
||||
} catch (error) {
|
||||
console.warn("Failed to disconnect Guacamole client", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyZoom = useCallback(
|
||||
(nextZoom: number): number => {
|
||||
const zoom = clampGuacamoleZoom(nextZoom);
|
||||
zoomRef.current = zoom;
|
||||
const display = clientRef.current?.getDisplay();
|
||||
if (display && displayRef.current) {
|
||||
const scale = fitScaleRef.current * zoom;
|
||||
scaleRef.current = scale;
|
||||
display.scale(scale);
|
||||
displayRef.current.style.width = `${display.getWidth() * scale}px`;
|
||||
displayRef.current.style.height = `${display.getHeight() * scale}px`;
|
||||
}
|
||||
onZoomChange?.(zoom);
|
||||
return zoom;
|
||||
},
|
||||
[onZoomChange],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: disconnectClient,
|
||||
isConnected: () => isReady && !hasError,
|
||||
sendKey: (keysym: number, pressed: boolean) => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.sendKeyEvent(pressed ? 1 : 0, keysym);
|
||||
}
|
||||
},
|
||||
sendMouse: (x: number, y: number, buttonMask: number) => {
|
||||
if (clientRef.current) {
|
||||
clientRef.current.sendMouseState(
|
||||
new Guacamole.Mouse.State({
|
||||
x,
|
||||
y,
|
||||
left: !!(buttonMask & 1),
|
||||
middle: !!(buttonMask & 2),
|
||||
right: !!(buttonMask & 4),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
setClipboard: (data: string) => {
|
||||
if (clientRef.current) {
|
||||
const stream = clientRef.current.createClipboardStream("text/plain");
|
||||
const writer = new Guacamole.StringWriter(stream);
|
||||
writer.sendText(data);
|
||||
writer.sendEnd();
|
||||
}
|
||||
},
|
||||
getFilesystem: () => filesystemRef.current,
|
||||
uploadFile: (file: File) => {
|
||||
const client = clientRef.current;
|
||||
if (!client) return Promise.reject(new Error("RDP session is not ready"));
|
||||
return uploadFileToClient(
|
||||
client as unknown as GuacamoleFileStreamClient,
|
||||
file,
|
||||
);
|
||||
},
|
||||
zoomIn: () => applyZoom(stepGuacamoleZoom(zoomRef.current, 1)),
|
||||
zoomOut: () => applyZoom(stepGuacamoleZoom(zoomRef.current, -1)),
|
||||
resetZoom: () => applyZoom(1),
|
||||
}));
|
||||
|
||||
const getWebSocketConnection = useCallback(
|
||||
async (
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
): Promise<{ url: string; query: string } | null> => {
|
||||
try {
|
||||
let token: string;
|
||||
const connectionProtocol =
|
||||
connectionConfig.protocol ?? connectionConfig.type;
|
||||
|
||||
// Resolved before the token is minted, not just before the socket is
|
||||
// opened: the token has to come from whichever backend will serve the
|
||||
// session, so both steps have to agree on the origin.
|
||||
const origin = await resolveConnectionOrigin(
|
||||
{ connectionOrigin: connectionConfig.connectionOrigin },
|
||||
{ defaultRemote: true },
|
||||
);
|
||||
|
||||
if (connectionConfig.token) {
|
||||
token = connectionConfig.token;
|
||||
} else {
|
||||
const data = await getGuacamoleToken(
|
||||
{
|
||||
protocol: connectionProtocol ?? "rdp",
|
||||
hostname: String(connectionConfig.hostname ?? ""),
|
||||
port: connectionConfig.port,
|
||||
username: connectionConfig.username,
|
||||
password: connectionConfig.password,
|
||||
domain: connectionConfig.domain,
|
||||
security:
|
||||
typeof connectionConfig.security === "string"
|
||||
? connectionConfig.security
|
||||
: undefined,
|
||||
ignoreCert:
|
||||
typeof connectionConfig.ignoreCert === "boolean"
|
||||
? connectionConfig.ignoreCert
|
||||
: undefined,
|
||||
guacamoleConfig: connectionConfig.guacamoleConfig as Parameters<
|
||||
typeof getGuacamoleToken
|
||||
>[0]["guacamoleConfig"],
|
||||
},
|
||||
origin,
|
||||
);
|
||||
token = data.token;
|
||||
}
|
||||
|
||||
const displaySize = getGuacamoleDisplaySize(
|
||||
connectionConfig.width ?? containerWidth ?? 1280,
|
||||
connectionConfig.height ?? containerHeight ?? 720,
|
||||
connectionProtocol,
|
||||
window.devicePixelRatio,
|
||||
connectionConfig.dpi,
|
||||
);
|
||||
|
||||
let wsBase: string | null;
|
||||
if (isElectron()) {
|
||||
// includeJwt stays off: a display authenticates with the encrypted
|
||||
// connection token in the query, not a session.
|
||||
const target = await buildOriginWsUrl({
|
||||
origin,
|
||||
localPort: 30001,
|
||||
localPath: "/plugin-ws/remote-desktop/display",
|
||||
remotePath: "/plugin-ws/remote-desktop/display",
|
||||
includeJwt: false,
|
||||
});
|
||||
if (!target) {
|
||||
onError?.(t("errors.remoteServerRequired"));
|
||||
return null;
|
||||
}
|
||||
wsBase = target.url;
|
||||
} else {
|
||||
wsBase = buildGuacamoleWebSocketBaseUrl({
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: getBasePath(),
|
||||
location: window.location,
|
||||
});
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
token,
|
||||
width: String(displaySize.width),
|
||||
height: String(displaySize.height),
|
||||
});
|
||||
if (displaySize.dpi) params.set("dpi", String(displaySize.dpi));
|
||||
return { url: wsBase, query: params.toString() };
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
onError?.(message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[connectionConfig, onError, t],
|
||||
);
|
||||
|
||||
const refreshKeyboardHandlers = useCallback(() => {
|
||||
const keyboard = keyboardRef.current;
|
||||
const client = clientRef.current;
|
||||
const displayElement = displayElementRef.current;
|
||||
|
||||
if (!keyboard) return;
|
||||
|
||||
const documentVisible =
|
||||
typeof document === "undefined" || document.visibilityState === "visible";
|
||||
const displayIsFocused =
|
||||
!!displayElement &&
|
||||
typeof document !== "undefined" &&
|
||||
document.activeElement === displayElement;
|
||||
const shouldCaptureInput =
|
||||
!!client &&
|
||||
!!displayElement &&
|
||||
isVisible &&
|
||||
documentVisible &&
|
||||
windowFocusedRef.current &&
|
||||
(hasKeyboardFocusRef.current || displayIsFocused);
|
||||
|
||||
if (!shouldCaptureInput) {
|
||||
keyboard.reset();
|
||||
keyboard.onkeydown = null;
|
||||
keyboard.onkeyup = null;
|
||||
return;
|
||||
}
|
||||
|
||||
keyboard.onkeydown = (keysym: number) => {
|
||||
if (!clientRef.current) return;
|
||||
if (!isVisible || !windowFocusedRef.current) return;
|
||||
|
||||
const activeDisplay = displayElementRef.current;
|
||||
const stillFocused =
|
||||
!!activeDisplay &&
|
||||
typeof document !== "undefined" &&
|
||||
document.activeElement === activeDisplay;
|
||||
|
||||
if (!hasKeyboardFocusRef.current && !stillFocused) return;
|
||||
clientRef.current.sendKeyEvent(1, keysym);
|
||||
};
|
||||
|
||||
keyboard.onkeyup = (keysym: number) => {
|
||||
// Focus loss must still release keys already sent to the remote host.
|
||||
clientRef.current?.sendKeyEvent(0, keysym);
|
||||
};
|
||||
}, [isVisible]);
|
||||
|
||||
const rescaleDisplay = useCallback(
|
||||
(immediate: boolean = false) => {
|
||||
if (!clientRef.current || !containerRef.current) return;
|
||||
|
||||
const performRescale = () => {
|
||||
if (!clientRef.current || !containerRef.current) return;
|
||||
|
||||
const display = clientRef.current.getDisplay();
|
||||
const cWidth = containerRef.current.clientWidth;
|
||||
const cHeight = containerRef.current.clientHeight;
|
||||
const displayWidth = display.getWidth();
|
||||
const displayHeight = display.getHeight();
|
||||
|
||||
if (
|
||||
displayWidth > 0 &&
|
||||
displayHeight > 0 &&
|
||||
cWidth > 0 &&
|
||||
cHeight > 0
|
||||
) {
|
||||
fitScaleRef.current = Math.min(
|
||||
cWidth / displayWidth,
|
||||
cHeight / displayHeight,
|
||||
);
|
||||
applyZoom(zoomRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
if (immediate) {
|
||||
performRescale();
|
||||
} else {
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
resizeTimeoutRef.current = setTimeout(performRescale, 200);
|
||||
}
|
||||
},
|
||||
[applyZoom],
|
||||
);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
if (isConnectingRef.current) return;
|
||||
isConnectingRef.current = true;
|
||||
setIsReady(false);
|
||||
setHasError(false);
|
||||
syncCountRef.current = 0;
|
||||
|
||||
// Let layout settle before measuring without depending on animation frames,
|
||||
// which may be throttled while Electron windows or tabs are inactive.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
if (!isMountedRef.current) {
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// The tab's DOM node can still be display:none (and report 0x0) when this
|
||||
// tab is restored in the background. Measuring then would force the
|
||||
// window-size fallback, which ignores the tab bar and makes the remote
|
||||
// resolution too tall (the bottom gets cut off). Poll briefly for a real
|
||||
// size before connecting so we capture the actual visible viewport.
|
||||
const measureContainer = () => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
return { width: rect?.width || 0, height: rect?.height || 0 };
|
||||
};
|
||||
|
||||
let { width: containerWidth, height: containerHeight } = measureContainer();
|
||||
for (
|
||||
let attempt = 0;
|
||||
(containerWidth < 100 || containerHeight < 100) && attempt < 40;
|
||||
attempt++
|
||||
) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||
if (!isMountedRef.current) {
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
({ width: containerWidth, height: containerHeight } = measureContainer());
|
||||
}
|
||||
|
||||
if (containerWidth < 100 || containerHeight < 100) {
|
||||
containerWidth = window.innerWidth || 1280;
|
||||
containerHeight = window.innerHeight || 720;
|
||||
}
|
||||
|
||||
const wsConnection = await getWebSocketConnection(
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
);
|
||||
if (!isMountedRef.current) {
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!wsConnection) {
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const tunnel = new Guacamole.WebSocketTunnel(wsConnection.url);
|
||||
const client = new Guacamole.Client(tunnel);
|
||||
clientRef.current = client;
|
||||
|
||||
const display = client.getDisplay();
|
||||
const displayElement = display.getElement();
|
||||
displayElementRef.current = displayElement;
|
||||
|
||||
if (displayRef.current) {
|
||||
displayRef.current.innerHTML = "";
|
||||
displayRef.current.appendChild(displayElement);
|
||||
}
|
||||
|
||||
displayElement.setAttribute("tabindex", "0");
|
||||
displayElement.style.outline = "none";
|
||||
|
||||
// Reading navigator.clipboard outside a user gesture is denied by Safari
|
||||
// and commonly denied by Chromium. The paste event carries the text under
|
||||
// the browser's normal permission model, so use it on every browser and
|
||||
// replace the original shortcut with an ordered clipboard update + Ctrl+V.
|
||||
displayElement.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (isPasteShortcut(event)) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
displayElement.addEventListener(
|
||||
"paste",
|
||||
(event) => {
|
||||
if (clientRef.current !== client) return;
|
||||
const text = event.clipboardData?.getData("text/plain");
|
||||
if (!text) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
pasteTextToRemote(client, text);
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
display.onresize = () => {
|
||||
if (!isMountedRef.current || clientRef.current !== client) return;
|
||||
rescaleDisplay(true);
|
||||
setIsReady(true);
|
||||
};
|
||||
|
||||
const protocol = connectionConfig.protocol ?? connectionConfig.type;
|
||||
if (protocol === "telnet" && isMountedRef.current) {
|
||||
setIsReady(true);
|
||||
}
|
||||
|
||||
const sendMouseState = (state: Guacamole.Mouse.State) => {
|
||||
displayElement.focus({ preventScroll: true });
|
||||
const scale = scaleRef.current;
|
||||
const adjustedState = new Guacamole.Mouse.State(
|
||||
Math.round(state.x / scale),
|
||||
Math.round(state.y / scale),
|
||||
state.left,
|
||||
state.middle,
|
||||
state.right,
|
||||
state.up,
|
||||
state.down,
|
||||
) as Guacamole.Mouse.State;
|
||||
client.sendMouseState(adjustedState);
|
||||
};
|
||||
|
||||
unbindPointerRef.current?.();
|
||||
unbindPointerRef.current = bindPointerInput(
|
||||
displayElement,
|
||||
touchMode,
|
||||
sendMouseState,
|
||||
);
|
||||
|
||||
const keyboard = new Guacamole.Keyboard(displayElement);
|
||||
keyboardRef.current = keyboard;
|
||||
|
||||
const handleDisplayFocus = () => {
|
||||
hasKeyboardFocusRef.current = true;
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
const handleDisplayBlur = () => {
|
||||
hasKeyboardFocusRef.current = false;
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
displayElement.addEventListener("focus", handleDisplayFocus);
|
||||
displayElement.addEventListener("blur", handleDisplayBlur);
|
||||
displayElement.addEventListener("mousedown", handleDisplayFocus);
|
||||
displayElement.addEventListener("touchstart", handleDisplayFocus, {
|
||||
passive: true,
|
||||
});
|
||||
refreshKeyboardHandlers();
|
||||
|
||||
client.onstatechange = (state: number) => {
|
||||
if (!isMountedRef.current || clientRef.current !== client) return;
|
||||
onStageChangeRef.current?.(guacStateToStage(state));
|
||||
switch (state) {
|
||||
case 0:
|
||||
break;
|
||||
case 1:
|
||||
break;
|
||||
case 2:
|
||||
break;
|
||||
case 3:
|
||||
isConnectingRef.current = false;
|
||||
if (protocol !== "rdp") {
|
||||
setIsReady(true);
|
||||
onConnect?.();
|
||||
}
|
||||
// A configured resolution is the size the session should render at;
|
||||
// resizing it to the container would discard it. rescaleDisplay still
|
||||
// fits that fixed display into whatever space is available.
|
||||
if (!hasConfiguredSize && containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const size = getGuacamoleDisplaySize(
|
||||
rect.width,
|
||||
rect.height,
|
||||
protocol,
|
||||
window.devicePixelRatio,
|
||||
connectionConfig.dpi,
|
||||
);
|
||||
client.sendSize(size.width, size.height);
|
||||
}
|
||||
rescaleDisplay(false);
|
||||
break;
|
||||
case 4:
|
||||
break;
|
||||
case 5:
|
||||
isConnectingRef.current = false;
|
||||
setIsReady(false);
|
||||
setHasError(true);
|
||||
hasKeyboardFocusRef.current = false;
|
||||
refreshKeyboardHandlers();
|
||||
onError?.(t("remoteDesktop.connectionError"));
|
||||
onDisconnect?.();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
client.onerror = (error: Guacamole.Status) => {
|
||||
if (!isMountedRef.current || clientRef.current !== client) return;
|
||||
const errorMessage = error.message || t("remoteDesktop.connectionError");
|
||||
setIsReady(false);
|
||||
setHasError(true);
|
||||
isConnectingRef.current = false;
|
||||
onError?.(errorMessage);
|
||||
};
|
||||
|
||||
if (protocol === "rdp") {
|
||||
client.onsync = () => {
|
||||
if (!isMountedRef.current || clientRef.current !== client) return;
|
||||
if (syncCountRef.current >= 2) return;
|
||||
syncCountRef.current += 1;
|
||||
if (syncCountRef.current >= 2) {
|
||||
setIsReady(true);
|
||||
onConnect?.();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
client.onclipboard = (stream: Guacamole.InputStream, mimetype: string) => {
|
||||
if (mimetype === "text/plain") {
|
||||
const reader = new Guacamole.StringReader(stream);
|
||||
let data = "";
|
||||
reader.ontext = (text: string) => {
|
||||
data += text;
|
||||
};
|
||||
reader.onend = () => {
|
||||
navigator.clipboard?.writeText?.(data).catch(() => {});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
client.onaudio = (stream: Guacamole.InputStream, mimetype: string) => {
|
||||
Guacamole.AudioPlayer.getInstance(stream, mimetype);
|
||||
};
|
||||
|
||||
// Only fires when the connection enables drive redirection; guacd exposes
|
||||
// the redirected drive as a single filesystem object.
|
||||
client.onfilesystem = (filesystem: Guacamole.Object) => {
|
||||
if (!isMountedRef.current || clientRef.current !== client) return;
|
||||
filesystemRef.current = filesystem;
|
||||
onFilesystemRef.current?.(filesystem);
|
||||
|
||||
filesystem.onundefine = () => {
|
||||
if (filesystemRef.current !== filesystem) return;
|
||||
filesystemRef.current = null;
|
||||
onFilesystemRef.current?.(null);
|
||||
};
|
||||
};
|
||||
|
||||
client.onfile = (
|
||||
stream: Guacamole.InputStream,
|
||||
mimetype: string,
|
||||
filename: string,
|
||||
) => {
|
||||
const reader = new Guacamole.BlobReader(stream, mimetype);
|
||||
reader.onend = () => {
|
||||
const blob = reader.getBlob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
stream.sendAck("OK", Guacamole.Status.Code.SUCCESS);
|
||||
};
|
||||
|
||||
try {
|
||||
client.connect(wsConnection.query);
|
||||
} catch (error) {
|
||||
isConnectingRef.current = false;
|
||||
if (!isMountedRef.current) return;
|
||||
setIsReady(false);
|
||||
setHasError(true);
|
||||
onError?.(errorMessage(error, t("remoteDesktop.connectionError")));
|
||||
}
|
||||
}, [
|
||||
getWebSocketConnection,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onError,
|
||||
refreshKeyboardHandlers,
|
||||
rescaleDisplay,
|
||||
connectionConfig.protocol,
|
||||
connectionConfig.type,
|
||||
connectionConfig.dpi,
|
||||
hasConfiguredSize,
|
||||
touchMode,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
if (isVisible && !hasInitiatedRef.current) {
|
||||
hasInitiatedRef.current = true;
|
||||
connect();
|
||||
}
|
||||
}, [isVisible, connect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
hasKeyboardFocusRef.current = false;
|
||||
}
|
||||
|
||||
refreshKeyboardHandlers();
|
||||
}, [isVisible, refreshKeyboardHandlers]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowFocus = () => {
|
||||
windowFocusedRef.current = true;
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
windowFocusedRef.current = false;
|
||||
hasKeyboardFocusRef.current = false;
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
windowFocusedRef.current =
|
||||
document.visibilityState === "visible" && document.hasFocus();
|
||||
if (document.visibilityState !== "visible") {
|
||||
hasKeyboardFocusRef.current = false;
|
||||
}
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
window.addEventListener("focus", handleWindowFocus);
|
||||
window.addEventListener("blur", handleWindowBlur);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleWindowFocus);
|
||||
window.removeEventListener("blur", handleWindowBlur);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [refreshKeyboardHandlers]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
hasInitiatedRef.current = false;
|
||||
isConnectingRef.current = false;
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
disconnectClient();
|
||||
displayElementRef.current = null;
|
||||
};
|
||||
}, [disconnectClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
|
||||
resizeTimeoutRef.current = setTimeout(() => {
|
||||
if (clientRef.current && containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
if (!hasConfiguredSize) {
|
||||
const size = getGuacamoleDisplaySize(
|
||||
rect.width,
|
||||
rect.height,
|
||||
connectionConfig.protocol ?? connectionConfig.type,
|
||||
window.devicePixelRatio,
|
||||
connectionConfig.dpi,
|
||||
);
|
||||
clientRef.current.sendSize(size.width, size.height);
|
||||
}
|
||||
rescaleDisplay(true);
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [
|
||||
connectionConfig.dpi,
|
||||
connectionConfig.protocol,
|
||||
connectionConfig.type,
|
||||
hasConfiguredSize,
|
||||
rescaleDisplay,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const protocol = connectionConfig.protocol ?? connectionConfig.type;
|
||||
if (!container || !isReady || protocol !== "vnc") return;
|
||||
|
||||
let pinchDistance = 0;
|
||||
let pinchZoom = zoomRef.current;
|
||||
const distance = (touches: TouchList) =>
|
||||
Math.hypot(
|
||||
touches[0].clientX - touches[1].clientX,
|
||||
touches[0].clientY - touches[1].clientY,
|
||||
);
|
||||
const onTouchStart = (event: TouchEvent) => {
|
||||
if (event.touches.length !== 2) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
pinchDistance = distance(event.touches);
|
||||
pinchZoom = zoomRef.current;
|
||||
};
|
||||
const onTouchMove = (event: TouchEvent) => {
|
||||
if (event.touches.length !== 2 || pinchDistance === 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
applyZoom(pinchZoom * (distance(event.touches) / pinchDistance));
|
||||
};
|
||||
const onTouchEnd = () => {
|
||||
pinchDistance = 0;
|
||||
};
|
||||
|
||||
container.addEventListener("touchstart", onTouchStart, {
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
container.addEventListener("touchmove", onTouchMove, {
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
container.addEventListener("touchend", onTouchEnd, { capture: true });
|
||||
return () => {
|
||||
container.removeEventListener("touchstart", onTouchStart, true);
|
||||
container.removeEventListener("touchmove", onTouchMove, true);
|
||||
container.removeEventListener("touchend", onTouchEnd, true);
|
||||
};
|
||||
}, [applyZoom, connectionConfig.protocol, connectionConfig.type, isReady]);
|
||||
|
||||
const canDropFiles = allowUpload && onDropFiles != null;
|
||||
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
|
||||
// Nested elements fire dragleave as the pointer crosses them, so track depth
|
||||
// rather than clearing the highlight on the first leave.
|
||||
const dragDepthRef = useRef(0);
|
||||
|
||||
const handleDragEnter = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
if (!hasDraggedFiles(event.dataTransfer.types)) return;
|
||||
event.preventDefault();
|
||||
if (!canDropFiles) return;
|
||||
dragDepthRef.current += 1;
|
||||
setIsDraggingFiles(true);
|
||||
},
|
||||
[canDropFiles],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setIsDraggingFiles(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
if (!hasDraggedFiles(event.dataTransfer.types)) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setIsDraggingFiles(false);
|
||||
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
const disposition = getFileDropDisposition(
|
||||
event.dataTransfer.types,
|
||||
files.length,
|
||||
canDropFiles,
|
||||
);
|
||||
if (disposition === "upload") onDropFiles?.(files);
|
||||
if (disposition === "reject") onDropUnavailable?.();
|
||||
},
|
||||
[canDropFiles, onDropFiles, onDropUnavailable],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 overflow-auto"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={(event) => {
|
||||
if (hasDraggedFiles(event.dataTransfer.types)) event.preventDefault();
|
||||
}}
|
||||
onDragLeave={canDropFiles ? handleDragLeave : undefined}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div
|
||||
ref={displayRef}
|
||||
className="relative flex min-h-full min-w-full items-center justify-center"
|
||||
style={{
|
||||
cursor: isReady ? "none" : "default",
|
||||
visibility: isReady ? "visible" : "hidden",
|
||||
}}
|
||||
/>
|
||||
|
||||
{isDraggingFiles && (
|
||||
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none bg-background/70 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 rounded-sm border-2 border-dashed border-border px-4 py-3 text-sm font-semibold">
|
||||
<Upload className="size-4" />
|
||||
{t("remoteDesktop.files.dropToUpload")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { errorMessage } from "./host-remote";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
ArrowUp,
|
||||
Download,
|
||||
File as FileIcon,
|
||||
Folder,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
downloadFile,
|
||||
listDirectory,
|
||||
parentPath,
|
||||
saveBlobAs,
|
||||
uploadFile,
|
||||
describeUploadError,
|
||||
type RemoteFileEntry,
|
||||
} from "./guacamole-filesystem.ts";
|
||||
|
||||
interface GuacamoleFileBrowserProps {
|
||||
filesystem: Guacamole.Object;
|
||||
allowUpload: boolean;
|
||||
allowDownload: boolean;
|
||||
pendingUploads: File[];
|
||||
onPendingUploadsHandled: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GuacamoleFileBrowser({
|
||||
filesystem,
|
||||
allowUpload,
|
||||
allowDownload,
|
||||
pendingUploads,
|
||||
onPendingUploadsHandled,
|
||||
onClose,
|
||||
}: GuacamoleFileBrowserProps) {
|
||||
const { t } = useTranslation();
|
||||
const [path, setPath] = useState("/");
|
||||
const [entries, setEntries] = useState<RemoteFileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyName, setBusyName] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (target: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setEntries(await listDirectory(filesystem, target));
|
||||
} catch (err) {
|
||||
setEntries([]);
|
||||
setError(errorMessage(err, t("remoteDesktop.files.listFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[filesystem, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh(path);
|
||||
}, [refresh, path]);
|
||||
|
||||
const upload = useCallback(
|
||||
async (files: File[]) => {
|
||||
for (const file of files) {
|
||||
setBusyName(file.name);
|
||||
try {
|
||||
await uploadFile(filesystem, path, file);
|
||||
toast.success(t("remoteDesktop.files.uploaded", { name: file.name }));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
describeUploadError(err, (key) =>
|
||||
t(`remoteDesktop.files.${key}`, { name: file.name }),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setBusyName(null);
|
||||
}
|
||||
}
|
||||
void refresh(path);
|
||||
},
|
||||
[filesystem, path, refresh, t],
|
||||
);
|
||||
|
||||
// Files dropped on the display land here so they share the browser's current
|
||||
// directory rather than always going to the drive root.
|
||||
useEffect(() => {
|
||||
if (pendingUploads.length === 0) return;
|
||||
onPendingUploadsHandled();
|
||||
if (!allowUpload) {
|
||||
toast.error(t("remoteDesktop.files.uploadDisabled"));
|
||||
return;
|
||||
}
|
||||
void upload(pendingUploads);
|
||||
}, [pendingUploads, onPendingUploadsHandled, allowUpload, upload, t]);
|
||||
|
||||
const download = useCallback(
|
||||
async (entry: RemoteFileEntry) => {
|
||||
setBusyName(entry.name);
|
||||
try {
|
||||
const { blob, filename } = await downloadFile(filesystem, entry.path);
|
||||
saveBlobAs(blob, filename);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("remoteDesktop.files.downloadFailed", { name: entry.name }),
|
||||
);
|
||||
} finally {
|
||||
setBusyName(null);
|
||||
}
|
||||
},
|
||||
[filesystem, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="absolute right-2 top-2 bottom-2 z-30 flex w-80 flex-col rounded-sm border border-border bg-background/95 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<span className="flex-1 truncate text-xs font-semibold">
|
||||
{t("remoteDesktop.files.title")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={onClose}
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
disabled={path === "/"}
|
||||
onClick={() => setPath(parentPath(path))}
|
||||
aria-label={t("remoteDesktop.files.parent")}
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
</Button>
|
||||
<span className="flex-1 truncate text-[11px] text-muted-foreground">
|
||||
{path}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={() => void refresh(path)}
|
||||
aria-label={t("remoteDesktop.files.refresh")}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && (
|
||||
<p className="px-2 py-3 text-[11px] text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<p className="px-2 py-3 text-[11px] text-red-500">{error}</p>
|
||||
)}
|
||||
{!loading && !error && entries.length === 0 && (
|
||||
<p className="px-2 py-3 text-[11px] text-muted-foreground">
|
||||
{t("remoteDesktop.files.empty")}
|
||||
</p>
|
||||
)}
|
||||
{!loading &&
|
||||
!error &&
|
||||
entries.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className="flex items-center gap-1.5 px-2 py-1 hover:bg-muted"
|
||||
>
|
||||
{entry.isDirectory ? (
|
||||
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FileIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 truncate text-left text-[11px] disabled:opacity-60"
|
||||
disabled={!entry.isDirectory}
|
||||
onClick={() => entry.isDirectory && setPath(entry.path)}
|
||||
>
|
||||
{entry.name}
|
||||
</button>
|
||||
{!entry.isDirectory && allowDownload && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5 shrink-0"
|
||||
disabled={busyName === entry.name}
|
||||
onClick={() => void download(entry)}
|
||||
aria-label={t("remoteDesktop.files.download")}
|
||||
>
|
||||
<Download className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allowUpload && (
|
||||
<div className="border-t border-border px-2 py-1.5">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
e.target.value = "";
|
||||
if (files.length > 0) void upload(files);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-full text-[11px]"
|
||||
disabled={busyName !== null}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="mr-1 size-3" />
|
||||
{busyName !== null
|
||||
? t("remoteDesktop.files.uploading", { name: busyName })
|
||||
: t("remoteDesktop.files.upload")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import {
|
||||
GripVertical,
|
||||
Monitor,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsLeftRight,
|
||||
FolderOpen,
|
||||
Touchpad,
|
||||
MousePointer,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Scan,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type {
|
||||
GuacamoleDisplayHandle,
|
||||
GuacamoleTouchMode,
|
||||
} from "./GuacamoleDisplay.tsx";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { ActionSlot } from "@termix/plugin-sdk/ui";
|
||||
import { cn } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
detectRuntimeMetaKeyFamily,
|
||||
metaKeyLabels,
|
||||
type MetaKeyFamily,
|
||||
} from "./guacamole-meta-key.ts";
|
||||
|
||||
interface GuacamoleToolbarProps {
|
||||
displayRef: React.RefObject<GuacamoleDisplayHandle>;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
touchMode?: GuacamoleTouchMode | null;
|
||||
hasFilesystem?: boolean;
|
||||
fileBrowserOpen?: boolean;
|
||||
onToggleFileBrowser?: () => void;
|
||||
onTouchModeChange?: (mode: GuacamoleTouchMode) => void;
|
||||
zoom?: number;
|
||||
onHide?: () => void;
|
||||
metaKeyFamily?: MetaKeyFamily;
|
||||
/** Handed to "remote-desktop.toolbar" actions (the share button). */
|
||||
slotContext?: RemoteDesktopToolbarContext;
|
||||
}
|
||||
|
||||
export const REMOTE_DESKTOP_TOOLBAR_SLOT = "remote-desktop.toolbar";
|
||||
|
||||
/** What a "remote-desktop.toolbar" action is invoked with. */
|
||||
export interface RemoteDesktopToolbarContext {
|
||||
hostId: number;
|
||||
/** guacd's connection id, once the session is up. */
|
||||
sessionId: string | null;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
tabInstanceId?: string;
|
||||
/** The backend the session runs on. */
|
||||
origin?: "local" | "remote";
|
||||
}
|
||||
|
||||
const MODIFIER_KEYSYMS = {
|
||||
ctrl: 0xffe3,
|
||||
alt: 0xffe9,
|
||||
shift: 0xffe1,
|
||||
win: 0xffeb,
|
||||
} as const;
|
||||
|
||||
const FKEY_KEYSYMS = Array.from({ length: 12 }, (_, i) => 0xffbe + i);
|
||||
|
||||
const BTN_BASE =
|
||||
"flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm whitespace-nowrap select-none";
|
||||
|
||||
const BTN_ICON =
|
||||
"flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm select-none";
|
||||
|
||||
const SEP = "w-px h-5 bg-border mx-0.5 shrink-0";
|
||||
|
||||
function TipBtn({
|
||||
tooltip,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tooltip: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tooltip}
|
||||
onClick={onClick}
|
||||
className={cn(BTN_BASE, className)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function TipIconBtn({
|
||||
tooltip,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tooltip: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tooltip}
|
||||
onClick={onClick}
|
||||
className={cn(BTN_ICON, className)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
displayRef,
|
||||
protocol,
|
||||
touchMode,
|
||||
hasFilesystem = false,
|
||||
fileBrowserOpen = false,
|
||||
onToggleFileBrowser,
|
||||
onTouchModeChange,
|
||||
zoom = 1,
|
||||
onHide,
|
||||
metaKeyFamily,
|
||||
slotContext,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const meta = metaKeyLabels(
|
||||
metaKeyFamily ?? detectRuntimeMetaKeyFamily(protocol),
|
||||
);
|
||||
const [position, setPosition] = useState({ x: 0, y: 12 });
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [showFKeys, setShowFKeys] = useState(false);
|
||||
const [stickyKeys, setStickyKeys] = useState<Record<number, boolean>>({
|
||||
[MODIFIER_KEYSYMS.ctrl]: false,
|
||||
[MODIFIER_KEYSYMS.alt]: false,
|
||||
[MODIFIER_KEYSYMS.shift]: false,
|
||||
[MODIFIER_KEYSYMS.win]: false,
|
||||
});
|
||||
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const dragOriginRef = useRef({ mouseX: 0, mouseY: 0, posX: 0, posY: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = toolbarRef.current;
|
||||
if (!el) return;
|
||||
const parent = el.offsetParent as HTMLElement | null;
|
||||
if (!parent) return;
|
||||
const parentW = parent.clientWidth;
|
||||
const toolbarW = el.offsetWidth;
|
||||
setPosition((p) => ({ ...p, x: Math.max(0, (parentW - toolbarW) / 2) }));
|
||||
}, [collapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!isDraggingRef.current) return;
|
||||
const parent = toolbarRef.current?.offsetParent as HTMLElement | null;
|
||||
const parentW = parent?.clientWidth ?? Infinity;
|
||||
const parentH = parent?.clientHeight ?? Infinity;
|
||||
const toolbarW = toolbarRef.current?.offsetWidth ?? 0;
|
||||
const toolbarH = toolbarRef.current?.offsetHeight ?? 0;
|
||||
|
||||
const dx = e.clientX - dragOriginRef.current.mouseX;
|
||||
const dy = e.clientY - dragOriginRef.current.mouseY;
|
||||
setPosition({
|
||||
x: Math.max(
|
||||
0,
|
||||
Math.min(dragOriginRef.current.posX + dx, parentW - toolbarW),
|
||||
),
|
||||
y: Math.max(
|
||||
0,
|
||||
Math.min(dragOriginRef.current.posY + dy, parentH - toolbarH),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
isDraggingRef.current = false;
|
||||
setIsDragging(false);
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
dragOriginRef.current = {
|
||||
mouseX: e.clientX,
|
||||
mouseY: e.clientY,
|
||||
posX: position.x,
|
||||
posY: position.y,
|
||||
};
|
||||
document.body.style.userSelect = "none";
|
||||
setIsDragging(true);
|
||||
},
|
||||
[position],
|
||||
);
|
||||
|
||||
const releaseStickyKeys = useCallback(() => {
|
||||
const display = displayRef.current;
|
||||
if (!display) return;
|
||||
setStickyKeys((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const [ksStr, active] of Object.entries(prev)) {
|
||||
if (active) {
|
||||
display.sendKey(Number(ksStr), false);
|
||||
next[Number(ksStr)] = false;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [displayRef]);
|
||||
|
||||
const sendCombo = useCallback(
|
||||
(...keysyms: number[]) => {
|
||||
const display = displayRef.current;
|
||||
if (!display) return;
|
||||
for (const k of keysyms) display.sendKey(k, true);
|
||||
for (const k of [...keysyms].reverse()) display.sendKey(k, false);
|
||||
releaseStickyKeys();
|
||||
},
|
||||
[displayRef, releaseStickyKeys],
|
||||
);
|
||||
|
||||
const toggleStickyKey = useCallback(
|
||||
(keysym: number) => {
|
||||
const display = displayRef.current;
|
||||
if (!display) return;
|
||||
setStickyKeys((prev) => {
|
||||
const isActive = prev[keysym];
|
||||
display.sendKey(keysym, !isActive);
|
||||
return { ...prev, [keysym]: !isActive };
|
||||
});
|
||||
},
|
||||
[displayRef],
|
||||
);
|
||||
|
||||
const isRdpVnc = protocol === "rdp" || protocol === "vnc";
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
zIndex: 20,
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
style={containerStyle}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{collapsed ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center bg-background/85 backdrop-blur-sm border border-border shadow-lg rounded-sm overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={startDrag}
|
||||
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="size-3" />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Monitor className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("remoteDesktop.toolbar.expand")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="flex items-center bg-background/85 backdrop-blur-sm border border-border shadow-lg rounded-sm px-0.5 py-0.5 gap-0">
|
||||
{/* Drag handle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={startDrag}
|
||||
className="flex items-center justify-center h-7 px-1 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("remoteDesktop.toolbar.dragHandle")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Touch mode toggle — touch devices only */}
|
||||
{touchMode != null && onTouchModeChange && (
|
||||
<>
|
||||
<div className={SEP} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onTouchModeChange(
|
||||
touchMode === "touchscreen"
|
||||
? "touchpad"
|
||||
: "touchscreen",
|
||||
)
|
||||
}
|
||||
className={cn(BTN_ICON)}
|
||||
>
|
||||
{touchMode === "touchscreen" ? (
|
||||
<MousePointer className="size-3.5" />
|
||||
) : (
|
||||
<Touchpad className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{touchMode === "touchscreen"
|
||||
? t("remoteDesktop.toolbar.switchToTrackpad")
|
||||
: t("remoteDesktop.toolbar.switchToTouch")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Drive files — only once guacd reports a redirected filesystem */}
|
||||
{hasFilesystem && onToggleFileBrowser && (
|
||||
<>
|
||||
<div className={SEP} />
|
||||
<TipIconBtn
|
||||
tooltip={t("remoteDesktop.files.title")}
|
||||
onClick={onToggleFileBrowser}
|
||||
className={cn(fileBrowserOpen && "bg-muted text-foreground")}
|
||||
>
|
||||
<FolderOpen className="size-3.5" />
|
||||
</TipIconBtn>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isRdpVnc && (
|
||||
<>
|
||||
<div className={SEP} />
|
||||
<TipIconBtn
|
||||
tooltip={t("remoteDesktop.toolbar.zoomOut")}
|
||||
onClick={() => displayRef.current?.zoomOut()}
|
||||
>
|
||||
<ZoomOut className="size-3.5" />
|
||||
</TipIconBtn>
|
||||
<TipBtn
|
||||
tooltip={t("remoteDesktop.toolbar.resetZoom")}
|
||||
onClick={() => displayRef.current?.resetZoom()}
|
||||
className="min-w-12 tabular-nums"
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
</TipBtn>
|
||||
<TipIconBtn
|
||||
tooltip={t("remoteDesktop.toolbar.zoomIn")}
|
||||
onClick={() => displayRef.current?.zoomIn()}
|
||||
>
|
||||
<ZoomIn className="size-3.5" />
|
||||
</TipIconBtn>
|
||||
<TipIconBtn
|
||||
tooltip={t("remoteDesktop.toolbar.fitToScreen")}
|
||||
onClick={() => displayRef.current?.resetZoom()}
|
||||
>
|
||||
<Scan className="size-3.5" />
|
||||
</TipIconBtn>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* System combos — RDP/VNC only */}
|
||||
{isRdpVnc && (
|
||||
<>
|
||||
<div className={SEP} />
|
||||
<TipBtn
|
||||
tooltip={t("remoteDesktop.toolbar.ctrlAltDel")}
|
||||
onClick={() => sendCombo(0xffe3, 0xffe9, 0xffff)}
|
||||
>
|
||||
CAD
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t(meta.lockTooltip)}
|
||||
onClick={() => sendCombo(MODIFIER_KEYSYMS.win, 0x006c)}
|
||||
>
|
||||
{meta.lock}
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t(meta.keyTooltip)}
|
||||
onClick={() => sendCombo(MODIFIER_KEYSYMS.win)}
|
||||
>
|
||||
{meta.short}
|
||||
</TipBtn>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sticky modifiers — RDP/VNC only */}
|
||||
{isRdpVnc && (
|
||||
<>
|
||||
<div className={SEP} />
|
||||
{(
|
||||
[
|
||||
[
|
||||
"ctrl",
|
||||
MODIFIER_KEYSYMS.ctrl,
|
||||
t("remoteDesktop.toolbar.ctrl"),
|
||||
],
|
||||
[
|
||||
"alt",
|
||||
MODIFIER_KEYSYMS.alt,
|
||||
t("remoteDesktop.toolbar.alt"),
|
||||
],
|
||||
[
|
||||
"shift",
|
||||
MODIFIER_KEYSYMS.shift,
|
||||
t("remoteDesktop.toolbar.shift"),
|
||||
],
|
||||
["win", MODIFIER_KEYSYMS.win, t(meta.stickyKey)],
|
||||
] as [string, number, string][]
|
||||
).map(([key, ks, label]) => (
|
||||
<Tooltip key={key}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleStickyKey(ks)}
|
||||
className={cn(
|
||||
BTN_BASE,
|
||||
stickyKeys[ks] &&
|
||||
"bg-primary/15 text-primary border border-primary/30",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{stickyKeys[ks]
|
||||
? t("remoteDesktop.toolbar.stickyActive", {
|
||||
key: label,
|
||||
})
|
||||
: t("remoteDesktop.toolbar.stickyInactive", {
|
||||
key: label,
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Function key toggle */}
|
||||
<div className={SEP} />
|
||||
<TipBtn
|
||||
tooltip={t("remoteDesktop.toolbar.fnToggle")}
|
||||
onClick={() => setShowFKeys((v) => !v)}
|
||||
className={cn(
|
||||
showFKeys &&
|
||||
"bg-primary/15 text-primary border border-primary/30",
|
||||
)}
|
||||
>
|
||||
Fn
|
||||
</TipBtn>
|
||||
|
||||
{/* F1-F12 row */}
|
||||
{showFKeys &&
|
||||
FKEY_KEYSYMS.map((ks, i) => (
|
||||
<TipBtn
|
||||
key={ks}
|
||||
tooltip={`F${i + 1}`}
|
||||
onClick={() => sendCombo(ks)}
|
||||
>
|
||||
F{i + 1}
|
||||
</TipBtn>
|
||||
))}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className={SEP} />
|
||||
<TipBtn
|
||||
tooltip={t("remoteDesktop.toolbar.esc")}
|
||||
onClick={() => sendCombo(0xff1b)}
|
||||
>
|
||||
Esc
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("remoteDesktop.toolbar.tab")}
|
||||
onClick={() => sendCombo(0xff09)}
|
||||
>
|
||||
Tab
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("remoteDesktop.toolbar.home")}
|
||||
onClick={() => sendCombo(0xff50)}
|
||||
>
|
||||
Home
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("remoteDesktop.toolbar.end")}
|
||||
onClick={() => sendCombo(0xff57)}
|
||||
>
|
||||
End
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("remoteDesktop.toolbar.pageUp")}
|
||||
onClick={() => sendCombo(0xff55)}
|
||||
>
|
||||
PgUp
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("remoteDesktop.toolbar.pageDown")}
|
||||
onClick={() => sendCombo(0xff56)}
|
||||
>
|
||||
PgDn
|
||||
</TipBtn>
|
||||
|
||||
{/* Arrow cluster */}
|
||||
<div className="flex flex-col ml-0.5">
|
||||
<div className="flex justify-center">
|
||||
<TipIconBtn
|
||||
tooltip={t("remoteDesktop.toolbar.arrowUp")}
|
||||
onClick={() => sendCombo(0xff52)}
|
||||
>
|
||||
<ChevronUp className="size-3" />
|
||||
</TipIconBtn>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<TipIconBtn
|
||||
tooltip={t("remoteDesktop.toolbar.arrowLeft")}
|
||||
onClick={() => sendCombo(0xff51)}
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
</TipIconBtn>
|
||||
<TipIconBtn
|
||||
tooltip={t("remoteDesktop.toolbar.arrowDown")}
|
||||
onClick={() => sendCombo(0xff54)}
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</TipIconBtn>
|
||||
<TipIconBtn
|
||||
tooltip={t("remoteDesktop.toolbar.arrowRight")}
|
||||
onClick={() => sendCombo(0xff53)}
|
||||
>
|
||||
<ChevronRight className="size-3" />
|
||||
</TipIconBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{slotContext && (
|
||||
<ActionSlot
|
||||
slotId={REMOTE_DESKTOP_TOOLBAR_SLOT}
|
||||
className={cn(BTN_ICON)}
|
||||
hideLabels
|
||||
when={{ ...slotContext }}
|
||||
context={() => [slotContext]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Collapse */}
|
||||
<div className="w-px h-5 bg-border mx-0.5 shrink-0" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(true)}
|
||||
className={cn(BTN_ICON)}
|
||||
>
|
||||
<ChevronsLeftRight className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("remoteDesktop.toolbar.collapse")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onHide && (
|
||||
<TipIconBtn
|
||||
tooltip={t("remoteDesktop.toolbar.hide")}
|
||||
onClick={onHide}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</TipIconBtn>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,309 @@
|
||||
import type { PluginApiClient, TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
isElectron,
|
||||
resolveRemoteHostId,
|
||||
type ConnectionOrigin,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { GuacamoleConfig } from "./guacamole-config";
|
||||
import { errorMessage } from "./host-remote";
|
||||
|
||||
let app: Pick<TermixApp, "apiFor"> | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setRemoteDesktopApp(next: Pick<TermixApp, "apiFor"> | null) {
|
||||
app = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the backend that will actually serve this session, so the token and
|
||||
* status come from the same place as the connection.
|
||||
*
|
||||
* Outside Electron there is only one backend. Inside it, a host left on
|
||||
* Default still resolves to the connected server -- asking the embedded
|
||||
* backend would report the guacd *it* can reach rather than the one serving
|
||||
* the session. A host explicitly set to "This Device" (Support#1240) is
|
||||
* served by the embedded backend against the user's own guacd, so its calls
|
||||
* have to go there instead.
|
||||
*
|
||||
* Callers pass the origin explicitly rather than defaulting it: a default
|
||||
* silently sent a missed call site to the remote server, which fails with a
|
||||
* bare network error on a desktop that has none configured.
|
||||
*/
|
||||
function guacamoleApi(origin: ConnectionOrigin): PluginApiClient {
|
||||
if (!app) throw new Error("Remote Desktop is not active");
|
||||
return app.apiFor(origin);
|
||||
}
|
||||
|
||||
function apiError(error: unknown, action: string): Error {
|
||||
return new Error(errorMessage(error, `Failed to ${action}`));
|
||||
}
|
||||
|
||||
export interface GuacamoleTokenRequest {
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
security?: string;
|
||||
ignoreCert?: boolean;
|
||||
guacamoleConfig?: GuacamoleConfig;
|
||||
}
|
||||
|
||||
export interface GuacamoleTokenResponse {
|
||||
token: string;
|
||||
guacamoleConnectionId?: string | null;
|
||||
termixConnectId?: string;
|
||||
}
|
||||
|
||||
type GuacamoleConfigSource = {
|
||||
guacamoleConfig?: string | Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function getGuacamoleDpi(
|
||||
source?: GuacamoleConfigSource,
|
||||
): number | undefined {
|
||||
const config = source?.guacamoleConfig;
|
||||
if (!config) return undefined;
|
||||
|
||||
let dpi: unknown;
|
||||
if (typeof config === "string") {
|
||||
try {
|
||||
dpi = JSON.parse(config).dpi;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
dpi = config.dpi;
|
||||
}
|
||||
|
||||
const parsedDpi = typeof dpi === "string" ? Number(dpi) : dpi;
|
||||
if (
|
||||
typeof parsedDpi !== "number" ||
|
||||
!Number.isFinite(parsedDpi) ||
|
||||
parsedDpi <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Math.trunc(parsedDpi);
|
||||
}
|
||||
|
||||
function toGuacamoleParams(
|
||||
config: GuacamoleTokenRequest["guacamoleConfig"],
|
||||
): Record<string, unknown> {
|
||||
if (!config) return {};
|
||||
|
||||
const params: Record<string, unknown> = {};
|
||||
|
||||
const mappings: Record<string, string> = {
|
||||
colorDepth: "color-depth",
|
||||
resizeMethod: "resize-method",
|
||||
forceLossless: "force-lossless",
|
||||
disableAudio: "disable-audio",
|
||||
enableAudioInput: "enable-audio-input",
|
||||
enableWallpaper: "enable-wallpaper",
|
||||
enableTheming: "enable-theming",
|
||||
enableFontSmoothing: "enable-font-smoothing",
|
||||
enableFullWindowDrag: "enable-full-window-drag",
|
||||
enableDesktopComposition: "enable-desktop-composition",
|
||||
enableMenuAnimations: "enable-menu-animations",
|
||||
disableBitmapCaching: "disable-bitmap-caching",
|
||||
disableOffscreenCaching: "disable-offscreen-caching",
|
||||
disableGlyphCaching: "disable-glyph-caching",
|
||||
disableGfx: "disable-gfx",
|
||||
enablePrinting: "enable-printing",
|
||||
printerName: "printer-name",
|
||||
enableDrive: "enable-drive",
|
||||
driveName: "drive-name",
|
||||
drivePath: "drive-path",
|
||||
createDrivePath: "create-drive-path",
|
||||
disableDownload: "disable-download",
|
||||
disableUpload: "disable-upload",
|
||||
enableTouch: "enable-touch",
|
||||
clientName: "client-name",
|
||||
initialProgram: "initial-program",
|
||||
serverLayout: "server-layout",
|
||||
gatewayHostname: "gateway-hostname",
|
||||
gatewayPort: "gateway-port",
|
||||
gatewayUsername: "gateway-username",
|
||||
gatewayPassword: "gateway-password",
|
||||
gatewayDomain: "gateway-domain",
|
||||
remoteApp: "remote-app",
|
||||
remoteAppDir: "remote-app-dir",
|
||||
remoteAppArgs: "remote-app-args",
|
||||
normalizeClipboard: "normalize-clipboard",
|
||||
disableCopy: "disable-copy",
|
||||
disablePaste: "disable-paste",
|
||||
swapRedBlue: "swap-red-blue",
|
||||
readOnly: "read-only",
|
||||
recordingPath: "recording-path",
|
||||
recordingName: "recording-name",
|
||||
createRecordingPath: "create-recording-path",
|
||||
recordingExcludeOutput: "recording-exclude-output",
|
||||
recordingExcludeMouse: "recording-exclude-mouse",
|
||||
recordingIncludeKeys: "recording-include-keys",
|
||||
wolSendPacket: "wol-send-packet",
|
||||
wolMacAddr: "wol-mac-addr",
|
||||
wolBroadcastAddr: "wol-broadcast-addr",
|
||||
wolUdpPort: "wol-udp-port",
|
||||
wolWaitTime: "wol-wait-time",
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
const paramName = mappings[key] || key;
|
||||
if (typeof value === "boolean") {
|
||||
params[paramName] = value ? "true" : "false";
|
||||
} else {
|
||||
params[paramName] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function getGuacamoleToken(
|
||||
request: GuacamoleTokenRequest,
|
||||
origin: ConnectionOrigin,
|
||||
): Promise<GuacamoleTokenResponse> {
|
||||
try {
|
||||
const guacParams = toGuacamoleParams(request.guacamoleConfig);
|
||||
|
||||
const response = await guacamoleApi(origin).post<GuacamoleTokenResponse>(
|
||||
"/token",
|
||||
{
|
||||
type: request.protocol,
|
||||
hostname: request.hostname,
|
||||
port: request.port,
|
||||
username: request.username,
|
||||
password: request.password,
|
||||
domain: request.domain,
|
||||
security: request.security,
|
||||
"ignore-cert": request.ignoreCert,
|
||||
...guacParams,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw apiError(error, "get a connection token");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGuacamoleTokenFromHost(
|
||||
hostId: number,
|
||||
origin: ConnectionOrigin,
|
||||
protocol?: "rdp" | "vnc" | "telnet",
|
||||
promptedCredentials?: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
},
|
||||
syncId?: string | null,
|
||||
tabInstanceId?: string,
|
||||
): Promise<GuacamoleTokenResponse> {
|
||||
try {
|
||||
// A locally-originated session is served by the embedded backend, which
|
||||
// knows the host by its local id -- remapping it onto the remote
|
||||
// server's id would address the wrong row, or fail outright when no
|
||||
// server is configured at all.
|
||||
const useRemote = isElectron() && origin === "remote";
|
||||
const remoteHostId = useRemote ? await resolveRemoteHostId(syncId) : null;
|
||||
if (useRemote && syncId && remoteHostId === null) {
|
||||
throw new Error("The synced host does not exist on the remote server");
|
||||
}
|
||||
const targetHostId = remoteHostId ?? hostId;
|
||||
const response = await guacamoleApi(origin).post<GuacamoleTokenResponse>(
|
||||
`/connect-host/${targetHostId}`,
|
||||
{
|
||||
...(protocol ? { protocol } : {}),
|
||||
...(tabInstanceId ? { tabInstanceId } : {}),
|
||||
...(promptedCredentials?.username
|
||||
? { promptedUsername: promptedCredentials.username }
|
||||
: {}),
|
||||
...(promptedCredentials?.password
|
||||
? { promptedPassword: promptedCredentials.password }
|
||||
: {}),
|
||||
...(promptedCredentials
|
||||
? { promptedDomain: promptedCredentials.domain ?? "" }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw apiError(error, "get a connection token");
|
||||
}
|
||||
}
|
||||
|
||||
export interface RemoteDesktopStatus {
|
||||
enabled: boolean;
|
||||
guacd: { status: string };
|
||||
}
|
||||
|
||||
/** `probe: false` skips dialing guacd, for a caller that only needs `enabled`. */
|
||||
export async function getGuacdStatus(
|
||||
origin: ConnectionOrigin,
|
||||
options: {
|
||||
probe?: boolean;
|
||||
hostId?: number;
|
||||
protocol?: "rdp" | "vnc" | "telnet";
|
||||
syncId?: string | null;
|
||||
} = {},
|
||||
): Promise<RemoteDesktopStatus> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.probe === false) params.set("probe", "0");
|
||||
if (options.hostId) {
|
||||
const useRemote = isElectron() && origin === "remote";
|
||||
const remoteHostId = useRemote
|
||||
? await resolveRemoteHostId(options.syncId)
|
||||
: null;
|
||||
if (useRemote && options.syncId && remoteHostId === null) {
|
||||
throw new Error("The synced host does not exist on the remote server");
|
||||
}
|
||||
params.set("hostId", String(remoteHostId ?? options.hostId));
|
||||
if (options.protocol) params.set("protocol", options.protocol);
|
||||
}
|
||||
const query = params.toString();
|
||||
const response = await guacamoleApi(origin).get<RemoteDesktopStatus>(
|
||||
query ? `/status?${query}` : "/status",
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getGuacamoleConnectionId(
|
||||
connectId: string,
|
||||
origin: ConnectionOrigin,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | null> {
|
||||
const response = await guacamoleApi(origin).get<{
|
||||
guacamoleConnectionId?: string | null;
|
||||
}>(`/connection/${encodeURIComponent(connectId)}`, { signal });
|
||||
return response.data.guacamoleConnectionId ?? null;
|
||||
}
|
||||
|
||||
export async function nativeRdpAvailable(): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
try {
|
||||
const response = await guacamoleApi("local").get<{ available: boolean }>(
|
||||
"/native-rdp",
|
||||
);
|
||||
return response.data.available === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Always the embedded backend: it is the one that can reach Electron. */
|
||||
export async function openNativeRdp(request: {
|
||||
host: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
domain?: string;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const response = await guacamoleApi("local").post<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>("/native-rdp", request);
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Guacamole from "guacamole-common-js";
|
||||
|
||||
const CONTROL_LEFT_KEYSYM = 0xffe3;
|
||||
const V_KEYSYM = 0x76;
|
||||
|
||||
interface ClipboardOutputStream {
|
||||
sendBlob(data: string): void;
|
||||
sendEnd(): void;
|
||||
}
|
||||
|
||||
export interface GuacamoleClipboardClient {
|
||||
createClipboardStream(mimetype: string): ClipboardOutputStream;
|
||||
sendKeyEvent(pressed: number, keysym: number): void;
|
||||
}
|
||||
|
||||
export function isPasteShortcut(
|
||||
event: Pick<KeyboardEvent, "altKey" | "ctrlKey" | "key" | "metaKey">,
|
||||
): boolean {
|
||||
return (
|
||||
!event.altKey &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key.toLowerCase() === "v"
|
||||
);
|
||||
}
|
||||
|
||||
export function pasteTextToRemote(
|
||||
client: GuacamoleClipboardClient,
|
||||
text: string,
|
||||
): void {
|
||||
const stream = client.createClipboardStream("text/plain");
|
||||
const writer = new Guacamole.StringWriter(stream);
|
||||
writer.sendText(text);
|
||||
writer.sendEnd();
|
||||
|
||||
client.sendKeyEvent(1, CONTROL_LEFT_KEYSYM);
|
||||
client.sendKeyEvent(1, V_KEYSYM);
|
||||
client.sendKeyEvent(0, V_KEYSYM);
|
||||
client.sendKeyEvent(0, CONTROL_LEFT_KEYSYM);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
declare module "guacamole-common-js" {
|
||||
namespace Guacamole {
|
||||
class Client {
|
||||
constructor(tunnel: Tunnel);
|
||||
connect(data?: string): void;
|
||||
disconnect(): void;
|
||||
getDisplay(): Display;
|
||||
sendKeyEvent(pressed: number, keysym: number): void;
|
||||
sendMouseState(state: Mouse.State): void;
|
||||
sendSize(width: number, height: number): void;
|
||||
setClipboard(stream: OutputStream, mimetype: string): void;
|
||||
createClipboardStream(mimetype: string): OutputStream;
|
||||
onstatechange: ((state: number) => void) | null;
|
||||
onerror: ((error: Status) => void) | null;
|
||||
onsync: ((timestamp: number) => void) | null;
|
||||
onclipboard: ((stream: InputStream, mimetype: string) => void) | null;
|
||||
onaudio: ((stream: InputStream, mimetype: string) => void) | null;
|
||||
onfile:
|
||||
| ((stream: InputStream, mimetype: string, filename: string) => void)
|
||||
| null;
|
||||
onfilesystem: ((filesystem: Object, name: string) => void) | null;
|
||||
}
|
||||
|
||||
// Mirrors Guacamole.Object: a named collection of streams. Within this
|
||||
// namespace `Object` refers to this class, not the global one.
|
||||
class Object {
|
||||
static readonly ROOT_STREAM: string;
|
||||
static readonly STREAM_INDEX_MIMETYPE: string;
|
||||
readonly index: number;
|
||||
requestInputStream(
|
||||
name: string,
|
||||
bodyCallback?: (stream: InputStream, mimetype: string) => void,
|
||||
): void;
|
||||
createOutputStream(mimetype: string, name: string): OutputStream;
|
||||
onbody: ((stream: InputStream, mimetype: string) => void) | null;
|
||||
onundefine: (() => void) | null;
|
||||
}
|
||||
|
||||
class AudioPlayer {
|
||||
static getInstance(
|
||||
stream: InputStream,
|
||||
mimetype: string,
|
||||
): AudioPlayer | null;
|
||||
sync(): void;
|
||||
}
|
||||
|
||||
class Display {
|
||||
getElement(): HTMLElement;
|
||||
getWidth(): number;
|
||||
getHeight(): number;
|
||||
scale(scale: number): void;
|
||||
onresize: (() => void) | null;
|
||||
}
|
||||
|
||||
class Tunnel {
|
||||
onerror: ((status: Status) => void) | null;
|
||||
onstatechange: ((state: number) => void) | null;
|
||||
}
|
||||
|
||||
class WebSocketTunnel extends Tunnel {
|
||||
constructor(url: string);
|
||||
}
|
||||
|
||||
class SessionRecording {
|
||||
constructor(source: Blob | Tunnel, refreshInterval?: number);
|
||||
onload: (() => void) | null;
|
||||
onerror: ((message: string) => void) | null;
|
||||
onprogress: ((duration: number, parsedSize: number) => void) | null;
|
||||
onplay: (() => void) | null;
|
||||
onpause: (() => void) | null;
|
||||
onseek:
|
||||
((position: number, current: number, total: number) => void) | null;
|
||||
getDisplay(): Display;
|
||||
getPosition(): number;
|
||||
getDuration(): number;
|
||||
isPlaying(): boolean;
|
||||
play(): void;
|
||||
pause(): void;
|
||||
seek(position: number, callback?: () => void): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
class Mouse {
|
||||
constructor(element: HTMLElement);
|
||||
onmousedown: ((state: Mouse.State) => void) | null;
|
||||
onmouseup: ((state: Mouse.State) => void) | null;
|
||||
onmousemove: ((state: Mouse.State) => void) | null;
|
||||
onmouseout: ((state: Mouse.State) => void) | null;
|
||||
}
|
||||
|
||||
namespace Mouse {
|
||||
class State {
|
||||
constructor(
|
||||
x: number,
|
||||
y: number,
|
||||
left?: boolean,
|
||||
middle?: boolean,
|
||||
right?: boolean,
|
||||
up?: boolean,
|
||||
down?: boolean,
|
||||
);
|
||||
constructor(state: {
|
||||
x: number;
|
||||
y: number;
|
||||
left?: boolean;
|
||||
middle?: boolean;
|
||||
right?: boolean;
|
||||
up?: boolean;
|
||||
down?: boolean;
|
||||
});
|
||||
x: number;
|
||||
y: number;
|
||||
left: boolean;
|
||||
middle: boolean;
|
||||
right: boolean;
|
||||
up: boolean;
|
||||
down: boolean;
|
||||
}
|
||||
|
||||
interface MouseEvent {
|
||||
state: Mouse.State;
|
||||
preventDefault(): void;
|
||||
stopPropagation(): void;
|
||||
}
|
||||
|
||||
class Touchpad {
|
||||
constructor(element: HTMLElement);
|
||||
currentState: Mouse.State;
|
||||
clickTimingThreshold: number;
|
||||
clickMoveThreshold: number;
|
||||
scrollThreshold: number;
|
||||
onEach(
|
||||
types: string[],
|
||||
listener: (event: Mouse.MouseEvent) => void,
|
||||
): void;
|
||||
on(type: string, listener: (event: Mouse.MouseEvent) => void): void;
|
||||
}
|
||||
|
||||
class Touchscreen {
|
||||
constructor(element: HTMLElement);
|
||||
currentState: Mouse.State;
|
||||
clickTimingThreshold: number;
|
||||
clickMoveThreshold: number;
|
||||
scrollThreshold: number;
|
||||
longPressThreshold: number;
|
||||
onEach(
|
||||
types: string[],
|
||||
listener: (event: Mouse.MouseEvent) => void,
|
||||
): void;
|
||||
on(type: string, listener: (event: Mouse.MouseEvent) => void): void;
|
||||
}
|
||||
}
|
||||
|
||||
class Keyboard {
|
||||
constructor(element: Document | HTMLElement);
|
||||
reset(): void;
|
||||
onkeydown: ((keysym: number) => void) | null;
|
||||
onkeyup: ((keysym: number) => void) | null;
|
||||
}
|
||||
|
||||
class Status {
|
||||
code: number;
|
||||
message: string;
|
||||
isError(): boolean;
|
||||
static readonly Code: {
|
||||
SUCCESS: number;
|
||||
UNSUPPORTED: number;
|
||||
SERVER_ERROR: number;
|
||||
SERVER_BUSY: number;
|
||||
UPSTREAM_TIMEOUT: number;
|
||||
UPSTREAM_ERROR: number;
|
||||
RESOURCE_NOT_FOUND: number;
|
||||
RESOURCE_CONFLICT: number;
|
||||
RESOURCE_CLOSED: number;
|
||||
CLIENT_BAD_REQUEST: number;
|
||||
CLIENT_UNAUTHORIZED: number;
|
||||
CLIENT_FORBIDDEN: number;
|
||||
CLIENT_TIMEOUT: number;
|
||||
};
|
||||
}
|
||||
|
||||
class InputStream {
|
||||
onblob: ((data: string) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
sendAck(message: string, code: number): void;
|
||||
}
|
||||
|
||||
class OutputStream {
|
||||
sendBlob(data: string): void;
|
||||
sendEnd(): void;
|
||||
}
|
||||
|
||||
class StringReader {
|
||||
constructor(stream: InputStream);
|
||||
ontext: ((text: string) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
}
|
||||
|
||||
class StringWriter {
|
||||
constructor(stream: OutputStream);
|
||||
sendText(text: string): void;
|
||||
sendEnd(): void;
|
||||
}
|
||||
|
||||
class BlobReader {
|
||||
constructor(stream: InputStream, mimetype: string);
|
||||
getBlob(): Blob;
|
||||
getLength(): number;
|
||||
onprogress: ((length: number) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
}
|
||||
|
||||
class BlobWriter {
|
||||
constructor(stream: OutputStream);
|
||||
sendBlob(blob: Blob): void;
|
||||
sendEnd(): void;
|
||||
onack: ((status: Status) => void) | null;
|
||||
onerror: ((blob: Blob, offset: number, error: Status) => void) | null;
|
||||
onprogress: ((blob: Blob, offset: number) => void) | null;
|
||||
oncomplete: ((blob: Blob) => void) | null;
|
||||
}
|
||||
}
|
||||
|
||||
export default Guacamole;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Per-host Guacamole connection settings, as edited in the host editor and
|
||||
* forwarded to guacd. Field names are camelCase here and translated to the
|
||||
* hyphenated protocol parameters in toGuacamoleParams().
|
||||
*/
|
||||
export interface GuacamoleConfig {
|
||||
colorDepth?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpi?: number;
|
||||
resizeMethod?: string;
|
||||
forceLossless?: boolean;
|
||||
disableAudio?: boolean;
|
||||
enableAudioInput?: boolean;
|
||||
enableWallpaper?: boolean;
|
||||
enableTheming?: boolean;
|
||||
enableFontSmoothing?: boolean;
|
||||
enableFullWindowDrag?: boolean;
|
||||
enableDesktopComposition?: boolean;
|
||||
enableMenuAnimations?: boolean;
|
||||
disableBitmapCaching?: boolean;
|
||||
disableOffscreenCaching?: boolean;
|
||||
disableGlyphCaching?: boolean;
|
||||
disableGfx?: boolean;
|
||||
enablePrinting?: boolean;
|
||||
printerName?: string;
|
||||
enableDrive?: boolean;
|
||||
driveName?: string;
|
||||
drivePath?: string;
|
||||
createDrivePath?: boolean;
|
||||
disableDownload?: boolean;
|
||||
disableUpload?: boolean;
|
||||
enableTouch?: boolean;
|
||||
clientName?: string;
|
||||
console?: boolean;
|
||||
initialProgram?: string;
|
||||
serverLayout?: string;
|
||||
timezone?: string;
|
||||
gatewayHostname?: string;
|
||||
gatewayPort?: number;
|
||||
gatewayUsername?: string;
|
||||
gatewayPassword?: string;
|
||||
gatewayDomain?: string;
|
||||
remoteApp?: string;
|
||||
remoteAppDir?: string;
|
||||
remoteAppArgs?: string;
|
||||
normalizeClipboard?: string;
|
||||
disableCopy?: boolean;
|
||||
disablePaste?: boolean;
|
||||
cursor?: string;
|
||||
swapRedBlue?: boolean;
|
||||
readOnly?: boolean;
|
||||
recordingPath?: string;
|
||||
recordingName?: string;
|
||||
createRecordingPath?: boolean;
|
||||
recordingExcludeOutput?: boolean;
|
||||
recordingExcludeMouse?: boolean;
|
||||
recordingIncludeKeys?: boolean;
|
||||
wolSendPacket?: boolean;
|
||||
wolMacAddr?: string;
|
||||
wolBroadcastAddr?: string;
|
||||
wolUdpPort?: number;
|
||||
wolWaitTime?: number;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
const DEFAULT_RDP_DPI = 96;
|
||||
const MAX_DEVICE_PIXEL_RATIO = 3;
|
||||
|
||||
/**
|
||||
* Reads a guacamoleConfig display field. The UI stores these as strings, and
|
||||
* leaves them empty when the size should follow the browser window.
|
||||
*/
|
||||
export function readConfiguredDimension(value: unknown): number | undefined {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
export interface GuacamoleDisplaySize {
|
||||
width: number;
|
||||
height: number;
|
||||
dpi?: number;
|
||||
pixelRatio: number;
|
||||
}
|
||||
|
||||
export function getGuacamoleDisplaySize(
|
||||
cssWidth: number,
|
||||
cssHeight: number,
|
||||
protocol: string | undefined,
|
||||
devicePixelRatio: number,
|
||||
configuredDpi?: number,
|
||||
): GuacamoleDisplaySize {
|
||||
const isRdp = protocol === "rdp";
|
||||
const pixelRatio = isRdp
|
||||
? Math.min(
|
||||
MAX_DEVICE_PIXEL_RATIO,
|
||||
Math.max(1, Number.isFinite(devicePixelRatio) ? devicePixelRatio : 1),
|
||||
)
|
||||
: 1;
|
||||
|
||||
const size = {
|
||||
width: Math.max(1, Math.round(cssWidth * pixelRatio)),
|
||||
height: Math.max(1, Math.round(cssHeight * pixelRatio)),
|
||||
pixelRatio,
|
||||
};
|
||||
|
||||
if (!isRdp) return size;
|
||||
|
||||
const baseDpi =
|
||||
configuredDpi && Number.isFinite(configuredDpi) && configuredDpi > 0
|
||||
? configuredDpi
|
||||
: DEFAULT_RDP_DPI;
|
||||
return { ...size, dpi: Math.round(baseDpi * pixelRatio) };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export type FileDropDisposition = "ignore" | "reject" | "upload";
|
||||
|
||||
export function hasDraggedFiles(types: readonly string[]): boolean {
|
||||
return types.includes("Files");
|
||||
}
|
||||
|
||||
export function getFileDropDisposition(
|
||||
types: readonly string[],
|
||||
fileCount: number,
|
||||
canUpload: boolean,
|
||||
): FileDropDisposition {
|
||||
if (!hasDraggedFiles(types) || fileCount === 0) return "ignore";
|
||||
return canUpload ? "upload" : "reject";
|
||||
}
|
||||
|
||||
export function canUploadToRdpDrive(
|
||||
uploadAllowed: boolean,
|
||||
driveEnabled: boolean,
|
||||
hasFilesystem: boolean,
|
||||
): boolean {
|
||||
return uploadAllowed && (driveEnabled || hasFilesystem);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import Guacamole from "guacamole-common-js";
|
||||
|
||||
// The bundled Guacamole runtime exposes Client.createFileStream(), but the
|
||||
// package's older TypeScript declaration omits it.
|
||||
export interface GuacamoleFileStreamClient {
|
||||
createFileStream(mimetype: string, filename: string): Guacamole.OutputStream;
|
||||
}
|
||||
|
||||
// The root stream of a Guacamole.Object maps stream name to mimetype; a stream
|
||||
// carrying that same mimetype is itself a directory.
|
||||
export const STREAM_INDEX_MIMETYPE =
|
||||
"application/vnd.glyptodon.guacamole.stream-index+json";
|
||||
|
||||
export interface RemoteFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
mimetype: string;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
export function isDirectoryMimetype(mimetype: string): boolean {
|
||||
return mimetype === STREAM_INDEX_MIMETYPE;
|
||||
}
|
||||
|
||||
export function joinPath(parent: string, name: string): string {
|
||||
return parent === "/" ? `/${name}` : `${parent}/${name}`;
|
||||
}
|
||||
|
||||
export function parentPath(path: string): string {
|
||||
const cut = path.lastIndexOf("/");
|
||||
return cut <= 0 ? "/" : path.slice(0, cut);
|
||||
}
|
||||
|
||||
export function basename(path: string): string {
|
||||
return path.slice(path.lastIndexOf("/") + 1) || path;
|
||||
}
|
||||
|
||||
// guacd returns absolute paths as keys, but a malformed or relative key would
|
||||
// otherwise produce a broken path on the next descent.
|
||||
export function parseDirectoryIndex(
|
||||
json: string,
|
||||
path: string,
|
||||
): RemoteFileEntry[] {
|
||||
const index = JSON.parse(json) as Record<string, string>;
|
||||
|
||||
return Object.entries(index)
|
||||
.map(([key, mimetype]) => {
|
||||
const name = basename(key);
|
||||
return {
|
||||
name,
|
||||
path: key.startsWith("/") ? key : joinPath(path, name),
|
||||
mimetype,
|
||||
isDirectory: isDirectoryMimetype(mimetype),
|
||||
};
|
||||
})
|
||||
.sort((a, b) =>
|
||||
a.isDirectory === b.isDirectory
|
||||
? a.name.localeCompare(b.name)
|
||||
: a.isDirectory
|
||||
? -1
|
||||
: 1,
|
||||
);
|
||||
}
|
||||
|
||||
// A refused stream is answered with an error ack that never reaches the body
|
||||
// callback, so every request needs its own deadline to avoid hanging forever.
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
|
||||
function requestStream(
|
||||
filesystem: Guacamole.Object,
|
||||
path: string,
|
||||
): Promise<{ stream: Guacamole.InputStream; mimetype: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error(`Timed out reading ${path}`)),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
filesystem.requestInputStream(path, (stream, mimetype) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ stream, mimetype });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function listDirectory(
|
||||
filesystem: Guacamole.Object,
|
||||
path: string,
|
||||
): Promise<RemoteFileEntry[]> {
|
||||
const { stream, mimetype } = await requestStream(filesystem, path);
|
||||
|
||||
if (!isDirectoryMimetype(mimetype)) {
|
||||
throw new Error(`${path} is not a directory`);
|
||||
}
|
||||
|
||||
const json = await new Promise<string>((resolve) => {
|
||||
const reader = new Guacamole.StringReader(stream);
|
||||
let text = "";
|
||||
reader.ontext = (chunk: string) => {
|
||||
text += chunk;
|
||||
};
|
||||
reader.onend = () => resolve(text);
|
||||
});
|
||||
|
||||
return parseDirectoryIndex(json, path);
|
||||
}
|
||||
|
||||
export async function downloadFile(
|
||||
filesystem: Guacamole.Object,
|
||||
path: string,
|
||||
): Promise<{ blob: Blob; filename: string }> {
|
||||
const { stream, mimetype } = await requestStream(filesystem, path);
|
||||
|
||||
if (isDirectoryMimetype(mimetype)) {
|
||||
throw new Error(`${path} is a directory`);
|
||||
}
|
||||
|
||||
const blob = await new Promise<Blob>((resolve) => {
|
||||
const reader = new Guacamole.BlobReader(stream, mimetype);
|
||||
reader.onend = () => resolve(reader.getBlob());
|
||||
});
|
||||
|
||||
return { blob, filename: basename(path) };
|
||||
}
|
||||
|
||||
export function uploadFile(
|
||||
filesystem: Guacamole.Object,
|
||||
directory: string,
|
||||
file: File,
|
||||
onProgress?: (sent: number, total: number) => void,
|
||||
): Promise<void> {
|
||||
return writeUpload(
|
||||
filesystem.createOutputStream(
|
||||
file.type || "application/octet-stream",
|
||||
joinPath(directory, file.name),
|
||||
),
|
||||
file,
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads through Guacamole's connection-level file stream. RDP exposes this
|
||||
* path even when guacd does not advertise a browsable filesystem object, so
|
||||
* display drops must not depend on `Client.onfilesystem` having fired.
|
||||
*/
|
||||
export function uploadFileToClient(
|
||||
client: GuacamoleFileStreamClient,
|
||||
file: File,
|
||||
onProgress?: (sent: number, total: number) => void,
|
||||
): Promise<void> {
|
||||
return writeUpload(createClientFileStream(client, file), file, onProgress);
|
||||
}
|
||||
|
||||
export function createClientFileStream(
|
||||
client: GuacamoleFileStreamClient,
|
||||
file: File,
|
||||
): Guacamole.OutputStream {
|
||||
return client.createFileStream(
|
||||
file.type || "application/octet-stream",
|
||||
file.name,
|
||||
);
|
||||
}
|
||||
|
||||
function writeUpload(
|
||||
stream: Guacamole.OutputStream,
|
||||
file: File,
|
||||
onProgress?: (sent: number, total: number) => void,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const writer = new Guacamole.BlobWriter(stream);
|
||||
|
||||
// A rejected blob stops the writer without firing onerror or oncomplete —
|
||||
// only the error ack reports it, so without this the upload hangs forever.
|
||||
writer.onack = (status: Guacamole.Status) => {
|
||||
if (status.isError()) {
|
||||
reject(
|
||||
new Error(
|
||||
status.message || `Server refused the upload of ${file.name}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
writer.onerror = () => reject(new Error(`Failed to upload ${file.name}`));
|
||||
writer.onprogress = (_blob: Blob, offset: number) =>
|
||||
onProgress?.(offset, file.size);
|
||||
writer.oncomplete = () => {
|
||||
writer.sendEnd();
|
||||
onProgress?.(file.size, file.size);
|
||||
resolve();
|
||||
};
|
||||
|
||||
writer.sendBlob(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function saveBlobAs(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns an upload failure into something a user can act on. guacd answers a
|
||||
* refused stream with an ack like "FAIL (CANNOT OPEN)" - accurate, but it
|
||||
* means "the drive folder isn't writable", which is what people need to hear.
|
||||
*/
|
||||
export function describeUploadError(
|
||||
error: unknown,
|
||||
t: (key: "driveNotWritable" | "driveUnavailable" | "uploadFailed") => string,
|
||||
): string {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (/cannot open|can't open|permission denied/i.test(message)) {
|
||||
return t("driveNotWritable");
|
||||
}
|
||||
if (/no fs/i.test(message)) {
|
||||
return t("driveUnavailable");
|
||||
}
|
||||
return message || t("uploadFailed");
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export type MetaKeyFamily = "win" | "super" | "cmd";
|
||||
|
||||
export interface MetaKeyLabels {
|
||||
family: MetaKeyFamily;
|
||||
short: string;
|
||||
lock: string;
|
||||
stickyKey: string;
|
||||
keyTooltip: string;
|
||||
lockTooltip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RDP is a Windows desktop, so the toolbar keeps Windows labels. VNC is used
|
||||
* for Linux and macOS as often as Windows; branding those sessions with a
|
||||
* Windows key is wrong, and the local device is the only OS we can see.
|
||||
*/
|
||||
export function detectMetaKeyFamily(
|
||||
protocol: "rdp" | "vnc" | "telnet",
|
||||
env: {
|
||||
platform?: string;
|
||||
userAgent?: string;
|
||||
userAgentDataPlatform?: string;
|
||||
} = {},
|
||||
): MetaKeyFamily {
|
||||
if (protocol === "rdp") return "win";
|
||||
|
||||
const uaPlatform = env.userAgentDataPlatform ?? "";
|
||||
const platform = env.platform ?? "";
|
||||
const userAgent = env.userAgent ?? "";
|
||||
const haystack = `${uaPlatform} ${platform} ${userAgent}`;
|
||||
|
||||
if (/iphone|ipad|ipod|mac/i.test(haystack)) return "cmd";
|
||||
if (/win/i.test(haystack)) return "win";
|
||||
return "super";
|
||||
}
|
||||
|
||||
export function detectRuntimeMetaKeyFamily(
|
||||
protocol: "rdp" | "vnc" | "telnet",
|
||||
): MetaKeyFamily {
|
||||
if (typeof navigator === "undefined") {
|
||||
return detectMetaKeyFamily(protocol);
|
||||
}
|
||||
const uaData = navigator as Navigator & {
|
||||
userAgentData?: { platform?: string };
|
||||
};
|
||||
return detectMetaKeyFamily(protocol, {
|
||||
platform: navigator.platform,
|
||||
userAgent: navigator.userAgent,
|
||||
userAgentDataPlatform: uaData.userAgentData?.platform,
|
||||
});
|
||||
}
|
||||
|
||||
export function metaKeyLabels(family: MetaKeyFamily): MetaKeyLabels {
|
||||
if (family === "cmd") {
|
||||
return {
|
||||
family,
|
||||
short: "Cmd",
|
||||
lock: "Cmd+L",
|
||||
stickyKey: "remoteDesktop.toolbar.cmd",
|
||||
keyTooltip: "remoteDesktop.toolbar.cmdKey",
|
||||
lockTooltip: "remoteDesktop.toolbar.cmdL",
|
||||
};
|
||||
}
|
||||
if (family === "super") {
|
||||
return {
|
||||
family,
|
||||
short: "Super",
|
||||
lock: "Super+L",
|
||||
stickyKey: "remoteDesktop.toolbar.super",
|
||||
keyTooltip: "remoteDesktop.toolbar.superKey",
|
||||
lockTooltip: "remoteDesktop.toolbar.superL",
|
||||
};
|
||||
}
|
||||
return {
|
||||
family,
|
||||
short: "Win",
|
||||
lock: "Win+L",
|
||||
stickyKey: "remoteDesktop.toolbar.win",
|
||||
keyTooltip: "remoteDesktop.toolbar.winKey",
|
||||
lockTooltip: "remoteDesktop.toolbar.winL",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import {
|
||||
createScrollCoalescer,
|
||||
isScrollButtonState,
|
||||
} from "./guacamole-scroll.js";
|
||||
|
||||
export type GuacamoleTouchMode = "touchscreen" | "touchpad";
|
||||
|
||||
/**
|
||||
* Guacamole.Mouse listens for mousedown/mousemove/mouseup; the Touchscreen and
|
||||
* Touchpad emulators listen only for touch events. The two sets do not overlap,
|
||||
* so binding one instead of the other leaves that input dead.
|
||||
*
|
||||
* A touch-capable device is not a device without a pointer: laptops with a
|
||||
* touchscreen report maxTouchPoints > 0 and are still driven by a mouse. The
|
||||
* physical pointer is therefore always bound, and a touch emulator is layered
|
||||
* on top when one is selected.
|
||||
*/
|
||||
export function bindPointerInput(
|
||||
element: HTMLElement,
|
||||
touchMode: GuacamoleTouchMode | null | undefined,
|
||||
onState: (state: Guacamole.Mouse.State) => void,
|
||||
): () => void {
|
||||
const coalescer = createScrollCoalescer((state) => {
|
||||
onState(state as Guacamole.Mouse.State);
|
||||
});
|
||||
let expectingScrollRelease = false;
|
||||
|
||||
const handleState = (state: Guacamole.Mouse.State) => {
|
||||
coalescer.notePointer(state);
|
||||
if (isScrollButtonState(state)) {
|
||||
coalescer.ingestClick(state.down ? 1 : -1);
|
||||
expectingScrollRelease = true;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
expectingScrollRelease &&
|
||||
!state.left &&
|
||||
!state.middle &&
|
||||
!state.right
|
||||
) {
|
||||
expectingScrollRelease = false;
|
||||
return;
|
||||
}
|
||||
expectingScrollRelease = false;
|
||||
onState(state);
|
||||
};
|
||||
|
||||
const mouse = new Guacamole.Mouse(element);
|
||||
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = handleState;
|
||||
|
||||
if (touchMode === "touchscreen") {
|
||||
const touchscreen = new Guacamole.Mouse.Touchscreen(element);
|
||||
touchscreen.onEach(["mousedown", "mousemove", "mouseup"], (event) =>
|
||||
handleState(event.state),
|
||||
);
|
||||
} else if (touchMode === "touchpad") {
|
||||
const touchpad = new Guacamole.Mouse.Touchpad(element);
|
||||
touchpad.onEach(["mousedown", "mousemove", "mouseup"], (event) =>
|
||||
handleState(event.state),
|
||||
);
|
||||
}
|
||||
|
||||
return () => coalescer.dispose();
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* guacamole-common-js turns each wheel tick into one or more mouse-button
|
||||
* clicks (button 4/5). High-resolution trackpads and the library's duplicate
|
||||
* wheel/mousewheel listeners can emit dozens of those clicks in a single
|
||||
* gesture. Each click is two Guacamole instructions, so a laggy path (a
|
||||
* Cloudflare-proxied remote session especially) queues them and the remote
|
||||
* desktop keeps scrolling long after the user stopped.
|
||||
*
|
||||
* Cap the in-flight click count and space flushes out so scrolling stays
|
||||
* usable without changing the websocket proxy.
|
||||
*/
|
||||
export const MAX_SCROLL_CLICKS_PER_FLUSH = 2;
|
||||
export const SCROLL_FLUSH_INTERVAL_MS = 32;
|
||||
|
||||
export type ScrollClickDirection = 1 | -1;
|
||||
|
||||
export interface GuacamolePointerState {
|
||||
x: number;
|
||||
y: number;
|
||||
left: boolean;
|
||||
middle: boolean;
|
||||
right: boolean;
|
||||
up: boolean;
|
||||
down: boolean;
|
||||
}
|
||||
|
||||
export function clampPendingScrollClicks(
|
||||
pending: number,
|
||||
delta: number,
|
||||
max = MAX_SCROLL_CLICKS_PER_FLUSH,
|
||||
): number {
|
||||
return Math.max(-max, Math.min(max, pending + delta));
|
||||
}
|
||||
|
||||
export function takeScrollClicks(pending: number): {
|
||||
clicks: number;
|
||||
direction: ScrollClickDirection | 0;
|
||||
} {
|
||||
if (pending === 0) return { clicks: 0, direction: 0 };
|
||||
const direction: ScrollClickDirection = pending < 0 ? -1 : 1;
|
||||
return {
|
||||
clicks: Math.min(MAX_SCROLL_CLICKS_PER_FLUSH, Math.abs(pending)),
|
||||
direction,
|
||||
};
|
||||
}
|
||||
|
||||
function mouseState(
|
||||
x: number,
|
||||
y: number,
|
||||
buttons: Pick<GuacamolePointerState, "left" | "middle" | "right">,
|
||||
up: boolean,
|
||||
down: boolean,
|
||||
): GuacamolePointerState {
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
left: buttons.left,
|
||||
middle: buttons.middle,
|
||||
right: buttons.right,
|
||||
up,
|
||||
down,
|
||||
};
|
||||
}
|
||||
|
||||
export function createScrollCoalescer(
|
||||
send: (state: GuacamolePointerState) => void,
|
||||
now: () => number = () => Date.now(),
|
||||
schedule: (fn: () => void, ms: number) => number = (fn, ms) =>
|
||||
window.setTimeout(fn, ms),
|
||||
cancel: (id: number) => void = (id) => window.clearTimeout(id),
|
||||
): {
|
||||
notePointer: (state: GuacamolePointerState) => void;
|
||||
ingestClick: (direction: ScrollClickDirection) => void;
|
||||
dispose: () => void;
|
||||
} {
|
||||
let pending = 0;
|
||||
let timer: number | null = null;
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let buttons = { left: false, middle: false, right: false };
|
||||
let lastFlushAt = 0;
|
||||
|
||||
const flush = () => {
|
||||
timer = null;
|
||||
const { clicks, direction } = takeScrollClicks(pending);
|
||||
pending = 0;
|
||||
if (clicks === 0 || direction === 0) return;
|
||||
lastFlushAt = now();
|
||||
const up = direction < 0;
|
||||
const down = direction > 0;
|
||||
for (let i = 0; i < clicks; i++) {
|
||||
send(mouseState(x, y, buttons, up, down));
|
||||
send(mouseState(x, y, buttons, false, false));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
notePointer(state) {
|
||||
x = state.x;
|
||||
y = state.y;
|
||||
buttons = {
|
||||
left: state.left,
|
||||
middle: state.middle,
|
||||
right: state.right,
|
||||
};
|
||||
},
|
||||
ingestClick(direction) {
|
||||
pending = clampPendingScrollClicks(pending, direction);
|
||||
const elapsed = now() - lastFlushAt;
|
||||
if (elapsed >= SCROLL_FLUSH_INTERVAL_MS) {
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
if (timer == null) {
|
||||
timer = schedule(flush, SCROLL_FLUSH_INTERVAL_MS - elapsed);
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
if (timer == null) return;
|
||||
cancel(timer);
|
||||
timer = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isScrollButtonState(state: {
|
||||
up?: boolean;
|
||||
down?: boolean;
|
||||
}): boolean {
|
||||
return state.up === true || state.down === true;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getGuacamoleConnectionId } from "./guacamole-api";
|
||||
import type { ConnectionOrigin } from "@termix/plugin-sdk/ui";
|
||||
|
||||
export function watchGuacamoleConnectionId(
|
||||
connectId: string,
|
||||
origin: ConnectionOrigin,
|
||||
onReady: (id: string) => void,
|
||||
): () => void {
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let attempts = 0;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const id = await getGuacamoleConnectionId(
|
||||
connectId,
|
||||
origin,
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
if (id) onReady(id);
|
||||
else if (++attempts < 20) timer = setTimeout(poll, 500);
|
||||
} catch {
|
||||
// Session sharing discovery must not interrupt the remote display.
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ConnectionStage } from "@termix/plugin-sdk/ui";
|
||||
|
||||
// Guacamole client states, per guacamole-common-js Guacamole.Client#STATE_*.
|
||||
const GUAC_STATE_IDLE = 0;
|
||||
const GUAC_STATE_CONNECTING = 1;
|
||||
const GUAC_STATE_WAITING = 2;
|
||||
const GUAC_STATE_CONNECTED = 3;
|
||||
const GUAC_STATE_DISCONNECTING = 4;
|
||||
const GUAC_STATE_DISCONNECTED = 5;
|
||||
|
||||
export function guacStateToStage(state: number): ConnectionStage {
|
||||
switch (state) {
|
||||
case GUAC_STATE_CONNECTING:
|
||||
return "guac_connecting";
|
||||
case GUAC_STATE_WAITING:
|
||||
return "guac_handshake";
|
||||
case GUAC_STATE_CONNECTED:
|
||||
return "guac_ready";
|
||||
case GUAC_STATE_DISCONNECTING:
|
||||
case GUAC_STATE_DISCONNECTED:
|
||||
return "guac_disconnected";
|
||||
case GUAC_STATE_IDLE:
|
||||
default:
|
||||
return "guac_connecting";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Where the Guacamole display socket lives.
|
||||
*
|
||||
* One path in every deployment: the plugin serves it at
|
||||
* /plugin-ws/remote-desktop/display on the main backend, so there is no
|
||||
* guacamole port and no separate nginx block any more. Electron with a remote
|
||||
* server is the only case that targets a different host.
|
||||
*/
|
||||
export function buildGuacamoleWebSocketBaseUrl({
|
||||
isElectronApp,
|
||||
isEmbeddedApp,
|
||||
configuredServerUrl,
|
||||
basePath,
|
||||
location,
|
||||
}: {
|
||||
isDev?: boolean;
|
||||
isElectronApp: boolean;
|
||||
isEmbeddedApp: boolean;
|
||||
configuredServerUrl?: string;
|
||||
basePath: string;
|
||||
location: Pick<Location, "protocol" | "host">;
|
||||
}) {
|
||||
const route = "/plugin-ws/remote-desktop/display";
|
||||
|
||||
if (isElectronApp && !isEmbeddedApp && configuredServerUrl) {
|
||||
const wsProtocol = configuredServerUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = configuredServerUrl
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "");
|
||||
return `${wsProtocol}${wsHost}${route}`;
|
||||
}
|
||||
|
||||
if (isElectronApp) return `ws://127.0.0.1:30001${route}`;
|
||||
|
||||
const wsProtocol = location.protocol === "https:" ? "wss" : "ws";
|
||||
return `${wsProtocol}://${location.host}${basePath}${route}`;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const MIN_GUACAMOLE_ZOOM = 0.5;
|
||||
export const MAX_GUACAMOLE_ZOOM = 4;
|
||||
export const GUACAMOLE_ZOOM_STEP = 0.25;
|
||||
|
||||
export function clampGuacamoleZoom(zoom: number): number {
|
||||
return Math.min(MAX_GUACAMOLE_ZOOM, Math.max(MIN_GUACAMOLE_ZOOM, zoom));
|
||||
}
|
||||
|
||||
export function stepGuacamoleZoom(zoom: number, direction: -1 | 1): number {
|
||||
return clampGuacamoleZoom(zoom + direction * GUACAMOLE_ZOOM_STEP);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
HostProtocolAuthSummary,
|
||||
PluginHostRecord,
|
||||
QuickConnectLogin,
|
||||
} 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 host fields a session reads. Each protocol's login is core's, under
|
||||
* protocolAuth, since this plugin declares its protocols in the manifest.
|
||||
*/
|
||||
export interface RemoteHostLogin {
|
||||
id?: number | string;
|
||||
name?: string | null;
|
||||
ip?: string;
|
||||
syncId?: string | null;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
protocolAuth?: Record<string, HostProtocolAuthSummary>;
|
||||
quickConnectLogin?: QuickConnectLogin;
|
||||
authOverrides?: Record<
|
||||
string,
|
||||
{
|
||||
credentialId?: number | string | null;
|
||||
required?: boolean;
|
||||
ownerAuthShared?: boolean;
|
||||
}
|
||||
>;
|
||||
pluginSettings?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** The RDP domain a host's saved login carries. */
|
||||
export function rdpDomain(host: RemoteHostLogin): string | undefined {
|
||||
return host.protocolAuth?.rdp?.fields?.domain || undefined;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import { useEffect, useState, type ComponentType, type Ref } from "react";
|
||||
import {
|
||||
Info,
|
||||
MessagesSquare,
|
||||
Monitor,
|
||||
MonitorUp,
|
||||
MousePointerClick,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
invokeAction,
|
||||
useTranslation,
|
||||
type HostEditorSectionProps,
|
||||
type PluginHostRecord,
|
||||
type StandaloneViewProps,
|
||||
type TabProps,
|
||||
type TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
FakeSwitch,
|
||||
HostFeatureFields,
|
||||
SectionCard,
|
||||
SettingRow,
|
||||
isElectron,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { toast } from "sonner";
|
||||
import GuacamoleApp, { type GuacamoleAppHandle } from "./GuacamoleApp";
|
||||
import { GuacamoleDisplay } from "./GuacamoleDisplay";
|
||||
import {
|
||||
HostEditorRdpTab,
|
||||
HostEditorTelnetTab,
|
||||
HostEditorVncTab,
|
||||
} from "./HostEditorGuacamoleTabs";
|
||||
import {
|
||||
getGuacamoleTokenFromHost,
|
||||
getGuacdStatus,
|
||||
nativeRdpAvailable,
|
||||
openNativeRdp,
|
||||
setRemoteDesktopApp,
|
||||
} from "./guacamole-api";
|
||||
import { quickConnectGuacHost } from "./quick-connect-guac-host";
|
||||
import { REMOTE_DESKTOP_TOOLBAR_SLOT } from "./GuacamoleToolbar.tsx";
|
||||
import {
|
||||
DEFAULT_PORT,
|
||||
ENABLE_KEY,
|
||||
PORT_KEY,
|
||||
hostRemoteOptions,
|
||||
isQuickConnectHost,
|
||||
protocolEnabled,
|
||||
type Protocol,
|
||||
rdpDomain,
|
||||
type RemoteHostLogin,
|
||||
} from "./host-remote";
|
||||
import { remoteDesktopForm } from "./remote-form";
|
||||
|
||||
const PROTOCOLS: {
|
||||
id: Protocol;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
paletteKey: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
priority: number;
|
||||
order: number;
|
||||
quickConnect?: { showDomain?: boolean };
|
||||
}[] = [
|
||||
{
|
||||
id: "rdp",
|
||||
titleKey: "hosts.tabRdp",
|
||||
descriptionKey: "hosts.remoteDesktop",
|
||||
paletteKey: "palette.connectRdp",
|
||||
icon: Monitor,
|
||||
priority: 50,
|
||||
order: 100,
|
||||
quickConnect: { showDomain: true },
|
||||
},
|
||||
{
|
||||
id: "vnc",
|
||||
titleKey: "hosts.tabVnc",
|
||||
descriptionKey: "hosts.virtualNetwork",
|
||||
paletteKey: "palette.connectVnc",
|
||||
icon: MousePointerClick,
|
||||
priority: 40,
|
||||
order: 110,
|
||||
quickConnect: {},
|
||||
},
|
||||
{
|
||||
id: "telnet",
|
||||
titleKey: "hosts.tabTelnet",
|
||||
descriptionKey: "hosts.unencryptedShell",
|
||||
paletteKey: "palette.connectTelnet",
|
||||
icon: MessagesSquare,
|
||||
priority: 30,
|
||||
order: 120,
|
||||
},
|
||||
];
|
||||
|
||||
function RemoteDesktopTab({ tab, host, isVisible, handleRef }: TabProps) {
|
||||
const record: RemoteHostLogin | undefined = host;
|
||||
return (
|
||||
<GuacamoleApp
|
||||
ref={handleRef as Ref<GuacamoleAppHandle>}
|
||||
hostId={String(host?.id ?? "")}
|
||||
tabId={tab.id}
|
||||
protocol={tab.type as Protocol}
|
||||
isVisible={isVisible}
|
||||
quickConnectHost={
|
||||
record && isQuickConnectHost(record)
|
||||
? quickConnectGuacHost(record)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoteDesktopStandalone({ hostId, view }: StandaloneViewProps) {
|
||||
return <GuacamoleApp hostId={hostId} protocol={view as Protocol} />;
|
||||
}
|
||||
|
||||
/** What collab rooms and shared-session links draw a stream with. */
|
||||
function RemoteDisplay({
|
||||
token,
|
||||
connectionOrigin,
|
||||
protocol,
|
||||
isVisible,
|
||||
onConnect,
|
||||
onError,
|
||||
}: {
|
||||
token: string;
|
||||
connectionOrigin?: "local" | "remote";
|
||||
protocol: Protocol;
|
||||
isVisible: boolean;
|
||||
onConnect?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<GuacamoleDisplay
|
||||
connectionConfig={{ token, protocol, type: protocol, connectionOrigin }}
|
||||
isVisible={isVisible}
|
||||
onConnect={onConnect}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionNotes() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("hosts.remoteDesktopNotes")}
|
||||
icon={<Info className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-2 py-3 text-xs text-muted-foreground">
|
||||
<p>{t("hosts.userDefaultsNote")}</p>
|
||||
{isElectron() && <p>{t("hosts.connectionOriginNote")}</p>}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarCard({
|
||||
form,
|
||||
setField,
|
||||
}: Pick<ReturnType<typeof remoteDesktopForm>, "form" | "setField">) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("hosts.guac.toolbar")}
|
||||
icon={<MonitorUp className="size-3.5" />}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("settings.host.enableToolbar.label")}
|
||||
description={t("settings.host.enableToolbar.description")}
|
||||
defaultKey="enableToolbar"
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.enableToolbar}
|
||||
onChange={(value) => setField("enableToolbar", value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Wake-on-LAN address a host keeps, the default for a session's wake
|
||||
* packet. Null while that plugin is off.
|
||||
*/
|
||||
function useWakeOnLanMac(hostId: string | undefined): string | null {
|
||||
const [mac, setMac] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!hostId) {
|
||||
setMac(null);
|
||||
return;
|
||||
}
|
||||
invokeAction("wakeOnLan.macAddress", hostId)
|
||||
.then((value) => {
|
||||
if (active) setMac(typeof value === "string" && value ? value : null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setMac(null);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [hostId]);
|
||||
return mac;
|
||||
}
|
||||
|
||||
function RdpSection(props: HostEditorSectionProps) {
|
||||
const { form, setField, setGuacField } = remoteDesktopForm(props);
|
||||
const macAddress = useWakeOnLanMac(props.host?.id);
|
||||
return (
|
||||
<>
|
||||
<HostEditorRdpTab
|
||||
form={form}
|
||||
setField={setField}
|
||||
setGuacField={setGuacField}
|
||||
host={{ macAddress }}
|
||||
credentials={props.credentials as never}
|
||||
/>
|
||||
<ToolbarCard form={form} setField={setField} />
|
||||
<SectionNotes />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VncSection(props: HostEditorSectionProps) {
|
||||
const { form, setField, setGuacField } = remoteDesktopForm(props);
|
||||
const macAddress = useWakeOnLanMac(props.host?.id);
|
||||
return (
|
||||
<>
|
||||
<HostEditorVncTab
|
||||
form={form}
|
||||
setField={setField}
|
||||
setGuacField={setGuacField}
|
||||
host={{ macAddress }}
|
||||
credentials={props.credentials as never}
|
||||
/>
|
||||
<ToolbarCard form={form} setField={setField} />
|
||||
<SectionNotes />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TelnetSection(props: HostEditorSectionProps) {
|
||||
const { form, setField, setGuacField } = remoteDesktopForm(props);
|
||||
return (
|
||||
<>
|
||||
<HostEditorTelnetTab
|
||||
form={form}
|
||||
setField={setField}
|
||||
setGuacField={setGuacField}
|
||||
credentials={props.credentials as never}
|
||||
/>
|
||||
<ToolbarCard form={form} setField={setField} />
|
||||
<SectionNotes />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The defaults editor's remote desktop section: the display settings and the
|
||||
* toolbar, which every host follows unless its own guacd settings say
|
||||
* otherwise. A host edits these in its protocol tabs instead.
|
||||
*/
|
||||
function DisplayDefaultsSection(props: HostEditorSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { form, setField } = remoteDesktopForm(props);
|
||||
return (
|
||||
<>
|
||||
<SectionCard
|
||||
title={t("settings.host.displayGroup")}
|
||||
icon={<Monitor className="size-3.5" />}
|
||||
>
|
||||
<HostFeatureFields form={props.form} updateForm={props.updateForm} />
|
||||
</SectionCard>
|
||||
<ToolbarCard form={form} setField={setField} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SECTIONS: Record<Protocol, ComponentType<HostEditorSectionProps>> = {
|
||||
rdp: RdpSection,
|
||||
vnc: VncSection,
|
||||
telnet: TelnetSection,
|
||||
};
|
||||
|
||||
function registerNativeRdp(app: TermixApp): void {
|
||||
let disposed = false;
|
||||
app.onDispose(() => {
|
||||
disposed = true;
|
||||
});
|
||||
void nativeRdpAvailable().then((available) => {
|
||||
if (disposed || !available) return;
|
||||
app.registerHostAction({
|
||||
id: "rdp-native",
|
||||
titleKey: "hosts.openNativeRdp",
|
||||
icon: MonitorUp,
|
||||
kind: "open",
|
||||
order: 105,
|
||||
when: (host) => protocolEnabled(host, "rdp"),
|
||||
run: (host) => {
|
||||
const record: RemoteHostLogin = host;
|
||||
void openNativeRdp({
|
||||
host: String(record.ip ?? ""),
|
||||
port: hostRemoteOptions(record).rdpPort,
|
||||
username: record.protocolAuth?.rdp?.username ?? undefined,
|
||||
domain: rdpDomain(record),
|
||||
})
|
||||
.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,
|
||||
connectionOriginNoteKey: "hosts.connectionOriginGuacamoleNote",
|
||||
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],
|
||||
});
|
||||
}
|
||||
|
||||
app.registerHostEditorSection({
|
||||
id: "remote-desktop-display",
|
||||
group: "top",
|
||||
titleKey: "settings.host.displayDefaultsTab",
|
||||
icon: Monitor,
|
||||
order: 40,
|
||||
defaults: "only",
|
||||
component: DisplayDefaultsSection,
|
||||
});
|
||||
|
||||
if (isElectron()) registerNativeRdp(app);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
setRemoteDesktopApp(app);
|
||||
app.onDispose(() => setRemoteDesktopApp(null));
|
||||
|
||||
// Guest pages only draw shared streams.
|
||||
app.registerSlotContribution("session.remoteDisplay", {
|
||||
actionId: "remote-desktop.display",
|
||||
titleKey: "hosts.tabRdp",
|
||||
kind: "component",
|
||||
component: RemoteDisplay as unknown as ComponentType<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
});
|
||||
if (app.guest) return;
|
||||
|
||||
// The share button lives here, contributed by session sharing.
|
||||
app.declareActionSlot({
|
||||
id: REMOTE_DESKTOP_TOOLBAR_SLOT,
|
||||
accepts: ["button"],
|
||||
});
|
||||
|
||||
// Mints a token for presenting a host in a collab room.
|
||||
app.registerAction(
|
||||
"session.remoteDisplay.token",
|
||||
async (hostId: number, origin: unknown, protocol: Protocol) => {
|
||||
const response = await getGuacamoleTokenFromHost(
|
||||
hostId,
|
||||
origin as Parameters<typeof getGuacamoleTokenFromHost>[1],
|
||||
protocol,
|
||||
);
|
||||
return response.guacamoleConnectionId
|
||||
? {
|
||||
token: response.token,
|
||||
connectionId: response.guacamoleConnectionId,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
);
|
||||
|
||||
for (const protocol of PROTOCOLS) {
|
||||
app.registerTab(protocol.id, RemoteDesktopTab, {
|
||||
icon: protocol.icon,
|
||||
titleKey: protocol.titleKey,
|
||||
requiresHost: true,
|
||||
noHostMessageKey: "remoteDesktop.noHostSelected",
|
||||
persistent: true,
|
||||
session: true,
|
||||
restore: (host) => protocolEnabled(host, protocol.id),
|
||||
activityTypes: [protocol.id],
|
||||
standalone: RemoteDesktopStandalone,
|
||||
preload: () => import("./GuacamoleApp"),
|
||||
});
|
||||
}
|
||||
|
||||
// An admin can turn Remote Desktop off; its ways in go with it.
|
||||
let disposed = false;
|
||||
app.onDispose(() => {
|
||||
disposed = true;
|
||||
});
|
||||
void getGuacdStatus("local", { probe: false })
|
||||
.then((status) => status.enabled !== false)
|
||||
.catch(() => true)
|
||||
.then((enabled) => {
|
||||
if (!disposed && enabled) registerHostSurfaces(app);
|
||||
});
|
||||
|
||||
app.registerSlotContribution("onboarding.features", {
|
||||
actionId: "remote-desktop.feature",
|
||||
titleKey: "onboarding.feature_desktop",
|
||||
descriptionKey: "onboarding.feature_desktop_desc",
|
||||
icon: Monitor,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { GuacamoleQuickHost } from "./GuacamoleApp";
|
||||
import {
|
||||
hostRemoteOptions,
|
||||
type Protocol,
|
||||
type RemoteHostLogin,
|
||||
} from "./host-remote";
|
||||
|
||||
/** The slice of a quick-connect host that GuacamoleApp mints a token from. */
|
||||
export function quickConnectGuacHost(
|
||||
host: RemoteHostLogin,
|
||||
): GuacamoleQuickHost {
|
||||
const options = hostRemoteOptions(host);
|
||||
const connectionType: Protocol = options.enableVnc ? "vnc" : "rdp";
|
||||
const login =
|
||||
host.quickConnectLogin?.protocol === connectionType
|
||||
? host.quickConnectLogin
|
||||
: undefined;
|
||||
return {
|
||||
name: host.name ?? undefined,
|
||||
ip: host.ip ?? "",
|
||||
connectionType,
|
||||
domain: login?.fields?.domain,
|
||||
port: connectionType === "vnc" ? options.vncPort : options.rdpPort,
|
||||
rdpAuthType: "direct",
|
||||
username: login?.username,
|
||||
password: login?.password,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { RemoteHostLogin } from "./host-remote";
|
||||
|
||||
export function needsRdpCredentialPrompt({
|
||||
protocol,
|
||||
rdpAuthType,
|
||||
authOverrides,
|
||||
}: {
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
rdpAuthType?: string;
|
||||
authOverrides?: RemoteHostLogin["authOverrides"];
|
||||
}): boolean {
|
||||
return (
|
||||
protocol === "rdp" &&
|
||||
rdpAuthType === "none" &&
|
||||
!authOverrides?.rdp?.credentialId
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type {
|
||||
HostEditorSectionProps,
|
||||
HostProtocolAuthForm,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { PLUGIN_ID, remoteOptions, type Protocol } 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's, under form.protocolAuth (this plugin declares the protocols in its
|
||||
* manifest); the options (ports, security, guacd settings) are this plugin's
|
||||
* host settings. Both are 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>;
|
||||
enableToolbar: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
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",
|
||||
enableToolbar: "enableToolbar",
|
||||
};
|
||||
|
||||
type LoginPart = "authType" | "credentialId" | "username" | "password";
|
||||
|
||||
/** Tab field name to the protocol login it edits. */
|
||||
const LOGIN_FOR_FIELD: Partial<
|
||||
Record<keyof RemoteDesktopForm, [Protocol, LoginPart | "domain"]>
|
||||
> = {
|
||||
rdpAuthType: ["rdp", "authType"],
|
||||
rdpCredentialId: ["rdp", "credentialId"],
|
||||
rdpUser: ["rdp", "username"],
|
||||
rdpPassword: ["rdp", "password"],
|
||||
domain: ["rdp", "domain"],
|
||||
vncAuthType: ["vnc", "authType"],
|
||||
vncCredentialId: ["vnc", "credentialId"],
|
||||
vncUser: ["vnc", "username"],
|
||||
vncPassword: ["vnc", "password"],
|
||||
telnetAuthType: ["telnet", "authType"],
|
||||
telnetCredentialId: ["telnet", "credentialId"],
|
||||
telnetUser: ["telnet", "username"],
|
||||
telnetPassword: ["telnet", "password"],
|
||||
};
|
||||
|
||||
type PluginSettingsBag = Record<string, Record<string, unknown>>;
|
||||
type LoginBag = Record<string, HostProtocolAuthForm>;
|
||||
|
||||
const EMPTY_LOGIN: HostProtocolAuthForm = {
|
||||
authType: "direct",
|
||||
credentialId: "",
|
||||
username: "",
|
||||
password: "",
|
||||
fields: {},
|
||||
};
|
||||
|
||||
export function loginOf(
|
||||
bag: LoginBag | undefined,
|
||||
protocol: Protocol,
|
||||
): HostProtocolAuthForm {
|
||||
const login = bag?.[protocol];
|
||||
return login ? { ...EMPTY_LOGIN, ...login } : EMPTY_LOGIN;
|
||||
}
|
||||
|
||||
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) } },
|
||||
};
|
||||
}
|
||||
|
||||
/** The form patch that sets one part of a protocol's login. */
|
||||
export function patchLogin(
|
||||
current: Record<string, unknown>,
|
||||
protocol: Protocol,
|
||||
part: LoginPart | "domain",
|
||||
value: unknown,
|
||||
): Record<string, unknown> {
|
||||
const bag = (current.protocolAuth as LoginBag | undefined) ?? {};
|
||||
const login = loginOf(bag, protocol);
|
||||
const next: HostProtocolAuthForm =
|
||||
part === "domain"
|
||||
? { ...login, fields: { ...login.fields, domain: String(value ?? "") } }
|
||||
: ({ ...login, [part]: value } as HostProtocolAuthForm);
|
||||
return { protocolAuth: { ...bag, [protocol]: next } };
|
||||
}
|
||||
|
||||
export function remoteDesktopForm(props: HostEditorSectionProps): {
|
||||
form: RemoteDesktopForm;
|
||||
setField: RemoteFormSetField;
|
||||
setGuacField: (key: string, value: unknown) => void;
|
||||
} {
|
||||
const bag = props.form?.pluginSettings as PluginSettingsBag | undefined;
|
||||
const logins = props.form?.protocolAuth as LoginBag | undefined;
|
||||
const options = remoteOptions(bag?.[PLUGIN_ID]);
|
||||
const rdp = loginOf(logins, "rdp");
|
||||
const vnc = loginOf(logins, "vnc");
|
||||
const telnet = loginOf(logins, "telnet");
|
||||
const form: RemoteDesktopForm = {
|
||||
rdpPort: options.rdpPort,
|
||||
vncPort: options.vncPort,
|
||||
telnetPort: options.telnetPort,
|
||||
security: options.rdpSecurity,
|
||||
ignoreCert: options.rdpIgnoreCert,
|
||||
guacamoleConfig: options.guacamoleConfig as Record<string, any>,
|
||||
enableToolbar: options.enableToolbar,
|
||||
domain: rdp.fields.domain ?? "",
|
||||
rdpAuthType: rdp.authType,
|
||||
rdpCredentialId: rdp.credentialId,
|
||||
rdpUser: rdp.username,
|
||||
rdpPassword: rdp.password,
|
||||
vncAuthType: vnc.authType === "credential" ? "credential" : "direct",
|
||||
vncCredentialId: vnc.credentialId,
|
||||
vncUser: vnc.username,
|
||||
vncPassword: vnc.password,
|
||||
telnetAuthType: telnet.authType === "credential" ? "credential" : "direct",
|
||||
telnetCredentialId: telnet.credentialId,
|
||||
telnetUser: telnet.username,
|
||||
telnetPassword: telnet.password,
|
||||
};
|
||||
|
||||
const setField: RemoteFormSetField = (key, value) => {
|
||||
const login = LOGIN_FOR_FIELD[key];
|
||||
if (login) {
|
||||
props.updateForm((current) =>
|
||||
patchLogin(current, login[0], login[1], value),
|
||||
);
|
||||
return;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user