release-2.6.0 (#1085)

* fix: general bug fixes

* fix: general qol additions

* ci(deps): bump actions/setup-node in the github-actions group (#1068)

Bumps the github-actions group with 1 update: [actions/setup-node](https://github.com/actions/setup-node).


Updates `actions/setup-node` from 6 to 7
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  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 28 updates (#1069)

Bumps the dev-patch-updates group with 28 updates:

| Package | From | To |
| --- | --- | --- |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.2` | `2.5.4` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.5` | `6.43.6` |
| [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.15` | `1.2.17` |
| [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.18` | `1.1.20` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.6` | `1.3.8` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.18` | `1.1.20` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.19` | `2.1.21` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.11` | `2.1.12` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.18` | `1.1.20` |
| [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.11` | `1.1.13` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.13` | `1.2.15` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.2` | `2.3.4` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.11` | `1.1.12` |
| [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.4.2` | `1.4.4` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.2` | `1.3.4` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.16` | `1.1.18` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.11` | `1.2.13` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.2` | `4.3.3` |
| [@uiw/codemirror-extensions-langs](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` |
| [@uiw/codemirror-theme-github](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` |
| [@uiw/react-codemirror](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.9` | `4.1.10` |
| [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.9` | `4.1.10` |
| [i18next](https://github.com/i18next/i18next) | `26.3.4` | `26.3.6` |
| [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.6.1` | `1.6.3` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.8` | `17.0.10` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.2` | `4.3.3` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.9` | `4.1.10` |


Updates `@biomejs/biome` from 2.5.2 to 2.5.4
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.5.4/packages/@biomejs/biome)

Updates `@codemirror/view` from 6.43.5 to 6.43.6
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@radix-ui/react-accordion` from 1.2.15 to 1.2.17
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/accordion/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/accordion)

Updates `@radix-ui/react-alert-dialog` from 1.1.18 to 1.1.20
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/alert-dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/alert-dialog)

Updates `@radix-ui/react-checkbox` from 1.3.6 to 1.3.8
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox)

Updates `@radix-ui/react-dialog` from 1.1.18 to 1.1.20
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog)

Updates `@radix-ui/react-dropdown-menu` from 2.1.19 to 2.1.21
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu)

Updates `@radix-ui/react-label` from 2.1.11 to 2.1.12
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label)

Updates `@radix-ui/react-popover` from 1.1.18 to 1.1.20
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover)

Updates `@radix-ui/react-progress` from 1.1.11 to 1.1.13
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress)

Updates `@radix-ui/react-scroll-area` from 1.2.13 to 1.2.15
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area)

Updates `@radix-ui/react-select` from 2.3.2 to 2.3.4
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select)

Updates `@radix-ui/react-separator` from 1.1.11 to 1.1.12
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator)

Updates `@radix-ui/react-slider` from 1.4.2 to 1.4.4
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider)

Updates `@radix-ui/react-switch` from 1.3.2 to 1.3.4
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch)

Updates `@radix-ui/react-tabs` from 1.1.16 to 1.1.18
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs)

Updates `@radix-ui/react-tooltip` from 1.2.11 to 1.2.13
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip)

Updates `@tailwindcss/vite` from 4.3.2 to 4.3.3
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/@tailwindcss-vite)

Updates `@uiw/codemirror-extensions-langs` from 4.25.10 to 4.25.11
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11)

Updates `@uiw/codemirror-theme-github` from 4.25.10 to 4.25.11
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11)

Updates `@uiw/react-codemirror` from 4.25.10 to 4.25.11
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11)

Updates `@vitest/coverage-v8` from 4.1.9 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/coverage-v8)

Updates `@vitest/ui` from 4.1.9 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/ui)

Updates `i18next` from 26.3.4 to 26.3.6
- [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.3.4...v26.3.6)

Updates `radix-ui` from 1.6.1 to 1.6.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui)

Updates `react-i18next` from 17.0.8 to 17.0.10
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.8...v17.0.10)

Updates `tailwindcss` from 4.3.2 to 4.3.3
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/tailwindcss)

Updates `vitest` from 4.1.9 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.5.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-accordion"
  dependency-version: 1.2.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-alert-dialog"
  dependency-version: 1.1.20
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-checkbox"
  dependency-version: 1.3.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-dialog"
  dependency-version: 1.1.20
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-version: 2.1.21
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-label"
  dependency-version: 2.1.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-popover"
  dependency-version: 1.1.20
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-progress"
  dependency-version: 1.1.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-scroll-area"
  dependency-version: 1.2.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-separator"
  dependency-version: 1.1.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-slider"
  dependency-version: 1.4.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-tabs"
  dependency-version: 1.1.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-tooltip"
  dependency-version: 1.2.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@uiw/codemirror-extensions-langs"
  dependency-version: 4.25.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@uiw/codemirror-theme-github"
  dependency-version: 4.25.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@uiw/react-codemirror"
  dependency-version: 4.25.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/ui"
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.3.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: radix-ui
  dependency-version: 1.6.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vitest
  dependency-version: 4.1.10
  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 ws in the prod-patch-updates group (#1071)

Bumps the prod-patch-updates group with 1 update: [ws](https://github.com/websockets/ws).


Updates `ws` from 8.21.0 to 8.21.1
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.21.0...8.21.1)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.21.1
  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>

* chore(deps): bump the major-updates group with 2 updates (#1072)

Bumps the major-updates group with 2 updates: [nanoid](https://github.com/ai/nanoid) and [typescript](https://github.com/microsoft/TypeScript).


Updates `nanoid` from 5.1.16 to 6.0.0
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/5.1.16...6.0.0)

Updates `typescript` from 6.0.3 to 7.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/commits)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix: general qol additions and new analytics/telemetrics feature

* fix: incorrect version sent to posthog

* feat: add multiplayer/shared sessions for terminal and guacd

* feat: rework Electron desktop app to run standalone-first with optional two-way sync to a remote Termix server

* Fix Guacamole tab visibility lifecycle (#1074)

Co-authored-by: default-student <default-student@github.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix(alerts): send channel config as object payload instead of JSON string (#1075)

* fix tmux-monitor tailscale issue (#1076)

* Initial plan

* fix(tmux-monitor): explicitly handle tailscale auth in PanePreview hostConfig

For Tailscale-auth hosts the pane-preview attach path was building the
Terminal hostConfig with only the generic spread of host fields.  This
could omit or mismap auth-critical details and trigger a plain TCP/SSH
reachability path that doesn't work with Tailscale-only SSH endpoints.

The fix branches on `host.authType === "tailscale"` and:
- Carries `authType: "tailscale"` explicitly so the backend always selects
  the Tailscale-aware PTY path regardless of how the host object evolves.
- Derives `port` from `host.sshPort ?? host.port` so Tailscale SSH
  endpoints on a non-default SSH port are reached correctly.
- Leaves all non-tailscale auth types on the unchanged code path.

Reattach (bumping instanceId + attachNonce) continues to work because
terminalHostConfig is recomputed on every render with the latest
instanceIdRef.current value.

* refactor(tmux-monitor): simplify tailscale port logic with extracted variable

Address code review feedback: extract resolvedPort into a local variable
to avoid the duplicated `host.sshPort ?? host.port` expression that was
assigned to both `port` and `sshPort`.  Restructure as an if/else block
instead of an IIFE for readability.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* chore: run prettier

* chore: update beta release text

* fix: cant update credential of an RDP host

* feat: add custom key shortcuts

* feat: add support for MFA over SSH

* fix: Invalid websocket frame causing code 10006 crash triggering restart loop

* fix(net): correct SSRF blocklist false-positive blocking all outbound IPv4 (#1079)

* fix: correct IPv4-mapped-IPv6 blocklist entry blocking all outbound IPv4

::ffff:0:0/96 in the IPv6 blocklist matches every IPv4 address once
mapped, since Node's BlockList compares addresses in their mapped form
internally regardless of the declared family. This caused
safeOutboundFetch to reject all IPv4-resolved destinations as private,
breaking outbound requests (e.g. ntfy/webhook notifications) whenever
DNS resolved to IPv4. Replaced with individual mapped ranges mirroring
the existing IPv4 blocklist.

* test: cover isBlockedAddress and link the Node BlockList citation

Exports isBlockedAddress so its family-crossing behavior around
IPv4-mapped-IPv6 addresses can actually be asserted, instead of relying
on manual container debugging to notice a regression. Also swaps the
prior "Node's BlockList compares addresses in mapped form" comment for
one citing the documented example in the Node docs
(https://nodejs.org/api/net.html#class-netblocklist), since that
behavior isn't otherwise obvious from the addSubnet/check call sites.

Related: Termix-SSH/Support#1024

* refactor: derive IPv6 mirror from IPv4 list, split DNS error messages

Two follow-ups from review:

- The IPv6 blocklist previously hand-duplicated each IPv4 range as its
  IPv4-mapped-IPv6 equivalent. Nothing enforced the two stayed in sync,
  which is exactly how the original bug (a mismatched ::ffff:0:0/96
  entry blocking all IPv4) was introduced in the first place. Now
  derived from a single blockedIpv4Ranges list in one loop.

- The connect.lookup hook threw the same "Private destinations are not
  allowed" for both an empty DNS result and an actually-blocked address.
  An empty result is a resolution failure, not a privacy decision, and
  conflating the two is the same kind of opaque-error problem that made
  this bug slow to diagnose in production. Split into distinct messages.

Also extracted the lookup hook itself (createDnsLookupHook) so it can be
unit-tested against a fake resolver directly, instead of only through a
real fetch()/Agent call — the bug lived entirely in this callback, and
undici wraps any error thrown here as a generic "fetch failed" TypeError,
which is why isolating it matters for testability.

---------

Co-authored-by: brennanneoh <497569+brennanneoh@users.noreply.github.com>

* fix(ssh): do not offer chacha20-poly1305 without the native ssh2 binding (#1081)

The availability probe treated a working OpenSSL "chacha20" cipher as proof
that chacha20-poly1305@openssh.com is usable. It is not: ssh2 pure-JS
chacha20-poly1305 corrupts the transport, so the peer aborts the KEX
("incomplete message [preauth]") and the connection times out. Easy to hit
on jump-host connections whose target sshd negotiates chacha20-poly1305
first.

Only trust the native binding (sshcrypto.node); otherwise leave the cipher
disabled so filterCiphers() drops it and AES-GCM is negotiated instead.

Co-authored-by: XtraLarge <>

* fix: add Swiss German server layout (#1078)

* chore: update release notes

* feat: continue improving desktop app 2-way sync with logic fixes and a migration dialog

* fix: dekstop app showing auth form without syncing

* feat: create desktop auto sessions for existing setups

* feat: add electron backend killing

* fix: electron login and session related bugs and updated readme for v2.6.0

* chore: finalize release notes

* fix: click to expand hosts including extra bottom margin

* fix: desktop auth modal failing to log users in

* fix: desktop app failing to sync

* fix: reverse proxy causing sync error

* chore: lint, format, and bump version to 2.6.0

* chore: sync Crowdin translations for 2.6.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: L.H. <117188168+default-student@users.noreply.github.com>
Co-authored-by: default-student <default-student@github.com>
Co-authored-by: Brad Baker <xyzulu@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Brennan Neoh <brennanneoh@users.noreply.github.com>
Co-authored-by: brennanneoh <497569+brennanneoh@users.noreply.github.com>
Co-authored-by: XtraLarge <eMail@WilliWerres.de>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
Luke Gustafson
2026-07-26 18:47:27 -05:00
committed by GitHub
co-authored by XtraLarge <> dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> L.H. default-student Brad Baker copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Copilot Autofix powered by AI Brennan Neoh brennanneoh XtraLarge ZacharyZcR
parent cf3e2cb499
commit 1a26628a48
232 changed files with 50585 additions and 32016 deletions
+68 -11
View File
@@ -2,9 +2,14 @@ import React, { useEffect, useState } from "react";
import { TabProvider } from "@/shell/TabContext.tsx";
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
import { SidebarProvider } from "@/components/sidebar.tsx";
import { getSSHHosts, getUserInfo } from "@/main-axios.ts";
import {
getSSHHosts,
getUserInfo,
isElectron,
requestDesktopAutoSession,
} from "@/main-axios.ts";
import type { SSHHost } from "@/types";
import { LoginScreen } from "@/auth/LoginScreen.tsx";
import { Auth } from "@/auth/Auth.tsx";
import { Toaster } from "@/components/sonner.tsx";
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
import { useTranslation } from "react-i18next";
@@ -38,20 +43,70 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
}, []);
useEffect(() => {
const checkAuth = async () => {
// Popped-out windows (terminal/tunnel/file-manager/etc.) share the main
// window's session rather than owning their own login -- there's no
// login form to fall back to here. On a cold Electron launch this
// window's renderer can start before the embedded backend has finished
// booting, so a bare failure isn't proof of "logged out"; in Electron
// it's retried forever (the embedded backend is bundled, always-on
// infrastructure that always eventually comes up) with a fallback to
// the same auto-session exchange the main window uses, since a login
// form must never appear for the local backend. Outside Electron a
// capped retry still applies, matching the web app's normal behavior.
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const maxAttempts = 10;
const checkAuth = async (attempt: number) => {
try {
const userInfo = await getUserInfo();
if (cancelled) return;
if (userInfo) {
setIsAuthenticated(true);
setAuthLoading(false);
return;
}
} catch {
setIsAuthenticated(false);
} finally {
setAuthLoading(false);
// fall through to retry/auto-session below
}
if (cancelled) return;
if (isElectron()) {
const outcome = await requestDesktopAutoSession();
if (cancelled) return;
if (outcome.kind === "success") {
setIsAuthenticated(true);
setAuthLoading(false);
return;
}
if (outcome.kind === "declined") {
setIsAuthenticated(false);
setAuthLoading(false);
return;
}
// "retry": keep retrying indefinitely with capped backoff.
const delay = Math.min(1000 * 2 ** attempt, 10000);
retryTimer = setTimeout(() => {
if (!cancelled) checkAuth(attempt + 1);
}, delay);
return;
}
if (attempt >= maxAttempts) {
setIsAuthenticated(false);
setAuthLoading(false);
return;
}
retryTimer = setTimeout(() => {
if (!cancelled) checkAuth(attempt + 1);
}, 1000);
};
checkAuth();
checkAuth(0);
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
};
}, []);
useEffect(() => {
@@ -84,6 +139,11 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
window.location.reload();
};
// Electron never reaches an unauthenticated render: checkAuth above
// retries and falls back to the same auto-session exchange the main
// window uses, so this branch is web-only there -- a real "not logged
// in" case for a bookmarked/shared full-screen link.
if (authLoading) {
return (
<div
@@ -110,10 +170,7 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
className="w-full h-screen overflow-hidden flex items-center justify-center"
style={{ backgroundColor: "var(--bg-base)" }}
>
<LoginScreen
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
/>
<Auth onLogin={handleAuthSuccess} />
<Toaster
position="bottom-right"
richColors={false}
@@ -15,6 +15,10 @@ import {
} from "@/components/select.tsx";
import { Card, CardContent } from "@/components/card.tsx";
import { getBasePath } from "@/lib/base-path";
import {
resolveConnectionOrigin,
buildOriginWsUrl,
} from "@/lib/connection-origin.ts";
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
import { toast } from "sonner";
import type { SSHHost } from "@/types";
@@ -23,6 +27,7 @@ import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { useTranslation } from "react-i18next";
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles";
import { useTheme } from "@/components/theme-provider";
interface ConsoleTerminalProps {
@@ -77,6 +82,7 @@ export function ConsoleTerminal({
(f) => f.value === terminalConfig.fontFamily,
);
const fontFamily = fontConfig?.fallback ?? TERMINAL_FONTS[0].fallback;
ensureTerminalFontsLoaded(fontConfig?.value ?? TERMINAL_FONTS[0].value);
terminal.options.cursorBlink = terminalConfig.cursorBlink;
terminal.options.cursorStyle = terminalConfig.cursorStyle;
@@ -263,7 +269,7 @@ export function ConsoleTerminal({
}
}, [terminal]);
const connect = React.useCallback(() => {
const connect = React.useCallback(async () => {
if (!terminal || containerState !== "running") {
toast.error(t("docker.containerMustBeRunning"));
return;
@@ -285,20 +291,30 @@ export function ConsoleTerminal({
window.location.port === "5173" ||
window.location.port === "");
const baseWsUrl = isDev
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`
: isElectronApp
? (() => {
const baseUrl =
(window as { configuredServerUrl?: string })
.configuredServerUrl || "http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, "");
return `${wsProtocol}${wsHost}/docker/console/`;
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
let baseWsUrl: string;
if (isDev) {
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`;
} else if (isElectronApp) {
const origin = await resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: hostConfig.connectionOrigin,
});
const resolvedUrl = await buildOriginWsUrl({
origin,
localPort: 30009,
localPath: "/docker/console/",
remotePath: "/docker/console/",
includeLocalJwt: false,
});
if (!resolvedUrl) {
setIsConnecting(false);
toast.error(t("errors.remoteServerRequired"));
return;
}
baseWsUrl = resolvedUrl;
} else {
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
}
const ws = new WebSocket(baseWsUrl);
+188 -23
View File
@@ -8,33 +8,49 @@ import React, {
import {
GuacamoleDisplay,
type GuacamoleDisplayHandle,
type GuacamoleTouchMode,
} from "@/features/guacamole/GuacamoleDisplay.tsx";
import {
getGuacamoleTokenFromHost,
getGuacdStatus,
getSSHHosts,
logActivity,
isElectron,
} from "@/main-axios.ts";
import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
import { useTranslation } from "react-i18next";
import { AlertCircle, RefreshCw } from "lucide-react";
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { PasswordInput } from "@/components/password-input.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog.tsx";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
import type { SSHHost } from "@/types";
interface GuacamoleAppProps {
hostId?: string;
tabId?: string;
protocol?: "rdp" | "vnc" | "telnet";
isVisible?: boolean;
}
export interface GuacamoleAppHandle {
disconnect: () => void;
isConnected: () => boolean;
openShareModal: () => void;
canShare: () => boolean;
}
const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
function GuacamoleApp({ hostId, tabId, protocol }, ref) {
function GuacamoleApp({ hostId, tabId, protocol, isVisible = true }, ref) {
const { t } = useTranslation();
const [hostConfig, setHostConfig] = useState<SSHHost | null>(null);
const [loading, setLoading] = useState(true);
@@ -88,6 +104,7 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
hostName={hostConfig.name || hostConfig.ip || String(hostId)}
tabId={tabId}
protocol={protocol}
isVisible={isVisible}
ref={ref}
/>
);
@@ -96,60 +113,134 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
interface GuacamoleAppInnerProps {
hostId: number;
hostConfig: Pick<SSHHost, "connectionType" | "guacamoleConfig">;
hostConfig: Pick<
SSHHost,
"connectionType" | "guacamoleConfig" | "rdpAuthType"
>;
hostName: string;
tabId?: string;
protocol?: "rdp" | "vnc" | "telnet";
isVisible: boolean;
}
const GuacamoleAppInner = React.forwardRef<
GuacamoleAppHandle,
GuacamoleAppInnerProps
>(function GuacamoleAppInner(
{ hostId, hostConfig, hostName, tabId, protocol },
{ hostId, hostConfig, hostName, tabId, protocol, isVisible },
ref,
) {
const { t } = useTranslation();
const [token, setToken] = useState<string | null>(null);
const [guacamoleConnectionId, setGuacamoleConnectionId] = useState<
string | null
>(null);
const [shareModalOpen, setShareModalOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [retryCount, setRetryCount] = useState(0);
const [touchMode, setTouchMode] = useState<GuacamoleTouchMode | null>(() =>
typeof window !== "undefined" &&
(navigator.maxTouchPoints > 0 || "ontouchstart" in window)
? "touchscreen"
: null,
);
const displayRef = useRef<GuacamoleDisplayHandle>(null);
const resolvedProtocolForConnect = (protocol ??
hostConfig.connectionType ??
"rdp") as "rdp" | "vnc" | "telnet";
const needsCredentialPrompt =
resolvedProtocolForConnect === "rdp" && hostConfig.rdpAuthType === "none";
const [promptedCredentials, setPromptedCredentials] = useState<{
username: string;
password: string;
} | null>(null);
const [promptOpen, setPromptOpen] = useState(needsCredentialPrompt);
const [promptUsername, setPromptUsername] = useState("");
const [promptPassword, setPromptPassword] = useState("");
useImperativeHandle(ref, () => ({
disconnect: () => displayRef.current?.disconnect(),
isConnected: () => displayRef.current?.isConnected() === true,
openShareModal: () => setShareModalOpen(true),
canShare: () => guacamoleConnectionId !== null,
}));
useEffect(() => {
if (needsCredentialPrompt && !promptedCredentials) {
setPromptOpen(true);
return;
}
setToken(null);
setGuacamoleConnectionId(null);
setError(null);
getGuacdStatus()
.then((status) => {
(async () => {
if (isElectron()) {
const origin = await resolveConnectionOrigin({
connectionType: resolvedProtocolForConnect,
});
if (origin === "remote") {
const remoteConfig = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
if (!remoteConfig?.serverUrl) {
setError(t("errors.remoteServerRequired"));
return;
}
}
}
try {
const status = await getGuacdStatus();
if (status.guacd.status !== "connected") {
setError(t("guacamole.guacdUnavailable"));
return;
}
return getGuacamoleTokenFromHost(hostId, protocol);
})
.then((result) => {
const result = await getGuacamoleTokenFromHost(
hostId,
protocol,
promptedCredentials ?? undefined,
);
if (result) {
setToken(result.token);
const resolvedProtocol = (protocol ??
hostConfig.connectionType ??
"rdp") as "rdp" | "vnc" | "telnet";
logActivity(resolvedProtocol, hostId, hostName).catch(() => {});
setGuacamoleConnectionId(result.guacamoleConnectionId ?? null);
logActivity(resolvedProtocolForConnect, hostId, hostName).catch(
() => {},
);
}
})
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
}, [hostConfig.connectionType, hostId, hostName, protocol, retryCount, t]);
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : t("guacamole.failedToConnect");
setError(message || t("guacamole.failedToConnect"));
}
})();
}, [
hostId,
hostName,
protocol,
retryCount,
t,
needsCredentialPrompt,
promptedCredentials,
resolvedProtocolForConnect,
]);
const handleReconnect = useCallback(() => {
setConnectionError(null);
setError(null);
setToken(null);
if (needsCredentialPrompt) {
setPromptedCredentials(null);
setPromptUsername("");
setPromptPassword("");
setPromptOpen(true);
}
setRetryCount((c) => c + 1);
}, []);
}, [needsCredentialPrompt]);
useEffect(() => {
if (!tabId) return;
@@ -162,6 +253,67 @@ const GuacamoleAppInner = React.forwardRef<
window.removeEventListener("termix:refresh-guacamole", handler);
}, [tabId, 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("guacamole.credentialPromptTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("guacamole.credentialPromptDescription")}
</DialogDescription>
</DialogHeader>
<form
className="flex flex-col gap-4 mt-1"
onSubmit={(e) => {
e.preventDefault();
setPromptedCredentials({
username: promptUsername,
password: promptPassword,
});
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.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("guacamole.connect")}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
if (error) {
return (
<div
@@ -209,10 +361,7 @@ const GuacamoleAppInner = React.forwardRef<
);
}
const resolvedProtocol = (protocol ?? hostConfig.connectionType) as
| "rdp"
| "vnc"
| "telnet";
const resolvedProtocol = resolvedProtocolForConnect;
const configuredDpi = Number(hostConfig.guacamoleConfig?.dpi);
return (
@@ -245,7 +394,7 @@ const GuacamoleAppInner = React.forwardRef<
</div>
)}
<GuacamoleDisplay
key={token}
key={`${token}-${touchMode}`}
ref={displayRef}
connectionConfig={{
token,
@@ -256,10 +405,26 @@ const GuacamoleAppInner = React.forwardRef<
? configuredDpi
: undefined,
}}
isVisible={true}
isVisible={isVisible}
touchMode={touchMode}
onError={(err) => setConnectionError(err)}
/>
<GuacamoleToolbar displayRef={displayRef} protocol={resolvedProtocol} />
<GuacamoleToolbar
displayRef={displayRef}
protocol={resolvedProtocol}
touchMode={touchMode}
onTouchModeChange={setTouchMode}
/>
{shareModalOpen && guacamoleConnectionId && (
<ShareSessionModal
open={shareModalOpen}
onClose={() => setShareModalOpen(false)}
hostId={hostId}
sessionId={guacamoleConnectionId}
protocol={resolvedProtocol}
tabInstanceId={tabId}
/>
)}
</div>
);
});
+112 -39
View File
@@ -8,10 +8,14 @@ import {
} from "react";
import Guacamole from "guacamole-common-js";
import { useTranslation } from "react-i18next";
import { getGuacamoleToken, isElectron, isEmbeddedMode } from "@/main-axios.ts";
import { getGuacamoleToken, isElectron } from "@/main-axios.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { getBasePath } from "@/lib/base-path.ts";
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
import {
resolveConnectionOrigin,
buildOriginWsUrl,
} from "@/lib/connection-origin.ts";
import {
isFirefoxBrowser,
isPasteShortcut,
@@ -44,9 +48,12 @@ export interface GuacamoleDisplayHandle {
setClipboard: (data: string) => void;
}
export type GuacamoleTouchMode = "touchscreen" | "touchpad";
interface GuacamoleDisplayProps {
connectionConfig: GuacamoleConnectionConfig;
isVisible: boolean;
touchMode?: GuacamoleTouchMode | null;
onConnect?: () => void;
onDisconnect?: () => void;
onError?: (error: string) => void;
@@ -58,7 +65,7 @@ export const GuacamoleDisplay = forwardRef<
GuacamoleDisplayHandle,
GuacamoleDisplayProps
>(function GuacamoleDisplay(
{ connectionConfig, isVisible, onConnect, onDisconnect, onError },
{ connectionConfig, isVisible, touchMode, onConnect, onDisconnect, onError },
ref,
) {
const { t } = useTranslation();
@@ -123,11 +130,11 @@ export const GuacamoleDisplay = forwardRef<
},
}));
const getWebSocketUrl = useCallback(
const getWebSocketConnection = useCallback(
async (
containerWidth: number,
containerHeight: number,
): Promise<string | null> => {
): Promise<{ url: string; query: string } | null> => {
try {
let token: string;
const connectionProtocol =
@@ -166,15 +173,31 @@ export const GuacamoleDisplay = forwardRef<
connectionConfig.dpi,
);
const wsBase = buildGuacamoleWebSocketBaseUrl({
isDev,
isElectronApp: isElectron(),
isEmbeddedApp: isEmbeddedMode(),
configuredServerUrl: (window as { configuredServerUrl?: string })
.configuredServerUrl,
basePath: getBasePath(),
location: window.location,
});
let wsBase: string | null;
if (isElectron()) {
const origin = await resolveConnectionOrigin({
connectionType: connectionProtocol,
});
wsBase = await buildOriginWsUrl({
origin,
localPort: 30008,
localPath: "/guacamole/websocket/",
remotePath: "/guacamole/websocket/",
includeLocalJwt: false,
});
if (!wsBase) {
onError?.(t("errors.remoteServerRequired"));
return null;
}
} else {
wsBase = buildGuacamoleWebSocketBaseUrl({
isDev,
isElectronApp: false,
isEmbeddedApp: false,
basePath: getBasePath(),
location: window.location,
});
}
const params = new URLSearchParams({
token,
@@ -182,7 +205,7 @@ export const GuacamoleDisplay = forwardRef<
height: String(displaySize.height),
});
if (displaySize.dpi) params.set("dpi", String(displaySize.dpi));
return `${wsBase}?${params.toString()}`;
return { url: wsBase, query: params.toString() };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
@@ -190,7 +213,7 @@ export const GuacamoleDisplay = forwardRef<
return null;
}
},
[connectionConfig, onError],
[connectionConfig, onError, t],
);
const refreshKeyboardHandlers = useCallback(() => {
@@ -285,10 +308,9 @@ export const GuacamoleDisplay = forwardRef<
setIsReady(false);
setHasError(false);
// Wait two frames so the container is fully laid out before measuring.
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
// 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;
@@ -310,9 +332,7 @@ export const GuacamoleDisplay = forwardRef<
(containerWidth < 100 || containerHeight < 100) && attempt < 40;
attempt++
) {
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
await new Promise<void>((resolve) => setTimeout(resolve, 25));
if (!isMountedRef.current) {
isConnectingRef.current = false;
return;
@@ -325,19 +345,28 @@ export const GuacamoleDisplay = forwardRef<
containerHeight = window.innerHeight || 720;
}
const wsUrl = await getWebSocketUrl(containerWidth, containerHeight);
const wsConnection = await getWebSocketConnection(
containerWidth,
containerHeight,
);
if (!isMountedRef.current) {
isConnectingRef.current = false;
return;
}
if (!wsUrl) {
if (!wsConnection) {
isConnectingRef.current = false;
return;
}
const tunnel = new Guacamole.WebSocketTunnel(wsUrl);
const tunnel = new Guacamole.WebSocketTunnel(wsConnection.url);
const client = new Guacamole.Client(tunnel);
clientRef.current = client;
let connectWatchdog: ReturnType<typeof setTimeout> | null = null;
const clearConnectWatchdog = () => {
if (!connectWatchdog) return;
clearTimeout(connectWatchdog);
connectWatchdog = null;
};
const display = client.getDisplay();
const displayElement = display.getElement();
@@ -378,7 +407,7 @@ export const GuacamoleDisplay = forwardRef<
}
display.onresize = () => {
if (!isMountedRef.current) return;
if (!isMountedRef.current || clientRef.current !== client) return;
rescaleDisplay(true);
setIsReady(true);
};
@@ -388,26 +417,46 @@ export const GuacamoleDisplay = forwardRef<
setIsReady(true);
}
const mouse = new Guacamole.Mouse(displayElement);
const sendMouseState = (state: Guacamole.Mouse.State) => {
const sendMouseEvent = (event: Guacamole.Mouse.MouseEvent) => {
displayElement.focus({ preventScroll: true });
const scale = scaleRef.current;
const adjustedX = Math.round(state.x / scale);
const adjustedY = Math.round(state.y / scale);
const state = event.state;
const adjustedState = new Guacamole.Mouse.State(
adjustedX,
adjustedY,
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);
};
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
if (touchMode === "touchscreen") {
const touchscreen = new Guacamole.Mouse.Touchscreen(displayElement);
touchscreen.onEach(["mousedown", "mousemove", "mouseup"], sendMouseEvent);
} else if (touchMode === "touchpad") {
const touchpad = new Guacamole.Mouse.Touchpad(displayElement);
touchpad.onEach(["mousedown", "mousemove", "mouseup"], sendMouseEvent);
} else {
const mouse = new Guacamole.Mouse(displayElement);
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);
};
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
}
const keyboard = new Guacamole.Keyboard(displayElement);
keyboardRef.current = keyboard;
@@ -425,10 +474,13 @@ export const GuacamoleDisplay = forwardRef<
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) return;
if (!isMountedRef.current || clientRef.current !== client) return;
switch (state) {
case 0:
break;
@@ -437,6 +489,7 @@ export const GuacamoleDisplay = forwardRef<
case 2:
break;
case 3:
clearConnectWatchdog();
isConnectingRef.current = false;
setIsReady(true);
onConnect?.();
@@ -456,16 +509,21 @@ export const GuacamoleDisplay = forwardRef<
case 4:
break;
case 5:
clearConnectWatchdog();
isConnectingRef.current = false;
setIsReady(false);
setHasError(true);
hasKeyboardFocusRef.current = false;
refreshKeyboardHandlers();
onError?.(t("guacamole.connectionError"));
onDisconnect?.();
break;
}
};
client.onerror = (error: Guacamole.Status) => {
if (!isMountedRef.current) return;
if (!isMountedRef.current || clientRef.current !== client) return;
clearConnectWatchdog();
const errorMessage = error.message || t("guacamole.connectionError");
setIsReady(false);
setHasError(true);
@@ -509,8 +567,21 @@ export const GuacamoleDisplay = forwardRef<
};
try {
client.connect();
connectWatchdog = setTimeout(() => {
if (
!isMountedRef.current ||
clientRef.current !== client ||
!isConnectingRef.current
) {
return;
}
disconnectClient();
void connect();
}, 8000);
client.connect(wsConnection.query);
} catch (error) {
clearConnectWatchdog();
isConnectingRef.current = false;
if (!isMountedRef.current) return;
setIsReady(false);
@@ -520,15 +591,17 @@ export const GuacamoleDisplay = forwardRef<
);
}
}, [
getWebSocketUrl,
getWebSocketConnection,
onConnect,
onDisconnect,
onError,
refreshKeyboardHandlers,
rescaleDisplay,
disconnectClient,
connectionConfig.protocol,
connectionConfig.type,
connectionConfig.dpi,
touchMode,
t,
]);
+43 -1
View File
@@ -13,6 +13,8 @@ import {
ChevronUp,
ChevronDown,
ChevronsLeftRight,
Touchpad,
MousePointer,
} from "lucide-react";
import {
Tooltip,
@@ -20,13 +22,18 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip.tsx";
import type { GuacamoleDisplayHandle } from "@/features/guacamole/GuacamoleDisplay.tsx";
import type {
GuacamoleDisplayHandle,
GuacamoleTouchMode,
} from "@/features/guacamole/GuacamoleDisplay.tsx";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
interface GuacamoleToolbarProps {
displayRef: React.RefObject<GuacamoleDisplayHandle>;
protocol: "rdp" | "vnc" | "telnet";
touchMode?: GuacamoleTouchMode | null;
onTouchModeChange?: (mode: GuacamoleTouchMode) => void;
}
const MODIFIER_KEYSYMS = {
@@ -107,6 +114,8 @@ function TipIconBtn({
export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
displayRef,
protocol,
touchMode,
onTouchModeChange,
}) => {
const { t } = useTranslation();
const [position, setPosition] = useState({ x: 0, y: 12 });
@@ -287,6 +296,39 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
</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("guacamole.toolbar.switchToTrackpad")
: t("guacamole.toolbar.switchToTouch")}
</TooltipContent>
</Tooltip>
</>
)}
{/* System combos — RDP/VNC only */}
{isRdpVnc && (
<>
+5 -24
View File
@@ -10,10 +10,10 @@ import { FitAddon } from "@xterm/addon-fit";
import { useTranslation } from "react-i18next";
import { TriangleAlert } from "lucide-react";
import { isElectron } from "@/lib/electron";
import { isEmbeddedMode } from "@/main-axios";
import { useTheme } from "@/components/theme-provider";
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles";
import type { SerialConfig } from "@/types/ui-types";
import type { SerialHandle } from "./serial-types";
@@ -67,6 +67,7 @@ export const Serial = forwardRef<SerialHandle, SerialProps>(function Serial(
const fontConfig = TERMINAL_FONTS.find(
(f) => f.value === DEFAULT_TERMINAL_CONFIG.fontFamily,
);
ensureTerminalFontsLoaded(fontConfig?.value ?? TERMINAL_FONTS[0].value);
terminal.options.theme = {
background: themeColors.background,
foreground: themeColors.foreground,
@@ -99,30 +100,10 @@ export const Serial = forwardRef<SerialHandle, SerialProps>(function Serial(
// ── WebSocket (Electron) path ──────────────────────────────────────────
const buildWsUrl = useCallback(() => {
const isDev =
!isElectron() &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
if (isDev || isEmbeddedMode()) {
const token = localStorage.getItem("jwt");
const base = "ws://127.0.0.1:30011";
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
}
const configuredUrl = (window as { configuredServerUrl?: string | null })
.configuredServerUrl;
if (!configuredUrl) return null;
const wsProtocol = configuredUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = configuredUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
// Serial is always local -- the device is physically attached to this
// desktop machine, so it never routes through a remote server.
const token = localStorage.getItem("jwt");
const base = `${wsProtocol}${wsHost}/serial/websocket/`;
const base = "ws://127.0.0.1:30011";
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
}, []);
@@ -0,0 +1,429 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Copy, Link2, Search, Shield, User, Users } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import { getUserList } from "@/main-axios";
import {
createSessionShare,
getActiveSessionShares,
revokeSessionShare,
type SessionShareProtocol,
type SessionSharePermissionLevel,
type SessionShareRecord,
} from "@/api/session-sharing-api";
const EXPIRY_PRESETS = [
{ key: "oneHour", hours: 1 },
{ key: "oneDay", hours: 24 },
{ key: "sevenDays", hours: 24 * 7 },
{ key: "thirtyDays", hours: 24 * 30 },
{ key: "custom", hours: undefined },
] as const;
type ExpiryPresetKey = (typeof EXPIRY_PRESETS)[number]["key"];
export function ShareSessionModal({
open,
onClose,
hostId,
sessionId,
protocol,
tabInstanceId,
}: {
open: boolean;
onClose: () => void;
hostId: number;
sessionId: string | null;
protocol: SessionShareProtocol;
tabInstanceId?: string;
}) {
const { t } = useTranslation();
const [mode, setMode] = useState<"link" | "user">("link");
const [permissionLevel, setPermissionLevel] =
useState<SessionSharePermissionLevel>("read-only");
const [expiryPreset, setExpiryPreset] = useState<ExpiryPresetKey>("oneDay");
const [customHours, setCustomHours] = useState("");
const [search, setSearch] = useState("");
const [users, setUsers] = useState<{ id: string; username: string }[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [createdLink, setCreatedLink] = useState<string | null>(null);
const [shares, setShares] = useState<SessionShareRecord[]>([]);
const [sharesLoaded, setSharesLoaded] = useState(false);
useEffect(() => {
if (!open) return;
setMode("link");
setPermissionLevel("read-only");
setExpiryPreset("oneDay");
setCustomHours("");
setSearch("");
setSelectedUserId(null);
setCreatedLink(null);
setSharesLoaded(false);
setShares([]);
}, [open, sessionId]);
useEffect(() => {
if (!open || sharesLoaded) return;
setSharesLoaded(true);
Promise.all([
getUserList().catch(() => ({ users: [] })),
getActiveSessionShares(hostId).catch(() => ({ shares: [] })),
]).then(([usersRes, sharesRes]) => {
setUsers(
(usersRes.users ?? []).map((u) => ({
id: String(u.userId),
username: u.username,
})),
);
setShares(sharesRes.shares ?? []);
});
}, [open, hostId, sharesLoaded]);
const filteredUsers = useMemo(() => {
const q = search.trim().toLowerCase();
return q
? users.filter((u) => u.username.toLowerCase().includes(q))
: users;
}, [users, search]);
const expiryHours = (() => {
if (expiryPreset === "custom") {
const hours = Number(customHours);
return Number.isFinite(hours) && hours > 0 ? hours : undefined;
}
return EXPIRY_PRESETS.find((p) => p.key === expiryPreset)?.hours;
})();
async function refreshShares() {
try {
const res = await getActiveSessionShares(hostId);
setShares(res.shares ?? []);
} catch {
// silently ignore
}
}
async function handleCreate() {
if (!sessionId) return;
if (mode === "user" && !selectedUserId) return;
if (expiryPreset === "custom" && !expiryHours) return;
setSubmitting(true);
try {
const result = await createSessionShare({
hostId,
sessionId,
tabInstanceId,
protocol,
shareType: mode,
targetUserId:
mode === "user" ? (selectedUserId ?? undefined) : undefined,
permissionLevel,
expiryHours,
});
if (mode === "link" && result.linkToken) {
const url = `${window.location.origin}${window.location.pathname}?view=shared&token=${result.linkToken}`;
setCreatedLink(url);
toast.success(t("sessionSharing.linkCreated"));
} else {
toast.success(t("sessionSharing.shareCreated"));
setSelectedUserId(null);
}
await refreshShares();
} catch (error) {
const status = (error as { status?: number })?.status;
if (mode === "user" && status === 403) {
toast.error(t("sessionSharing.userLacksHostAccess"));
} else {
toast.error(t("sessionSharing.shareFailed"));
}
} finally {
setSubmitting(false);
}
}
async function handleCopyLink() {
if (!createdLink) return;
try {
await navigator.clipboard.writeText(createdLink);
toast.success(t("sessionSharing.linkCopied"));
} catch {
// clipboard API unavailable, ignore
}
}
async function handleRevoke(shareId: string) {
try {
await revokeSessionShare(shareId);
setShares((prev) => prev.filter((s) => s.id !== shareId));
toast.success(t("sessionSharing.revoked"));
} catch {
toast.error(t("sessionSharing.revokeFailed"));
}
}
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("sessionSharing.modalTitle")}</DialogTitle>
<DialogDescription>
{mode === "link"
? t("sessionSharing.linkModeDescription")
: t("sessionSharing.userModeDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex gap-1.5">
{(["link", "user"] as const).map((m) => (
<button
key={m}
onClick={() => {
setMode(m);
setCreatedLink(null);
}}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${mode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{m === "link" ? (
<Link2 className="size-3 shrink-0" />
) : (
<User className="size-3 shrink-0" />
)}
{m === "link"
? t("sessionSharing.modeTab.link")
: t("sessionSharing.modeTab.user")}
</button>
))}
</div>
{mode === "user" && (
<>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50" />
<Input
placeholder={t("sessionSharing.searchUsersPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex flex-col border border-border h-28 overflow-y-auto">
{filteredUsers.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
{t("sessionSharing.noUsersFound")}
</div>
) : (
filteredUsers.map((user) => {
const isSelected = selectedUserId === user.id;
return (
<button
key={user.id}
onClick={() => setSelectedUserId(user.id)}
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
>
<div
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{isSelected && (
<Check className="size-2.5 text-background" />
)}
</div>
<User className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">{user.username}</span>
</button>
);
})
)}
</div>
</>
)}
<div className="flex items-end gap-2">
<div className="flex flex-col gap-1 flex-1 min-w-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("sessionSharing.permissionLevel.label")}
</span>
<select
value={permissionLevel}
onChange={(e) =>
setPermissionLevel(
e.target.value as SessionSharePermissionLevel,
)
}
className="h-8 w-full px-2.5 text-xs border border-border bg-background hover:bg-muted/40 transition-colors"
>
<option value="read-only">
{t("sessionSharing.permissionLevel.readOnly")}
</option>
<option value="read-write">
{t("sessionSharing.permissionLevel.readWrite")}
</option>
</select>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex flex-col gap-1 shrink-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-left">
{t("sessionSharing.expiryLabel")}
</span>
<span className="h-8 flex items-center justify-center px-2.5 text-xs border border-border hover:bg-muted/40 transition-colors whitespace-nowrap">
{t(`hosts.sharing.expiry.${expiryPreset}`)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
{EXPIRY_PRESETS.map((preset) => (
<DropdownMenuItem
key={preset.key}
onClick={() => setExpiryPreset(preset.key)}
>
{expiryPreset === preset.key ? (
<Check className="size-3 mr-1.5" />
) : (
<span className="size-3 mr-1.5" />
)}
{t(`hosts.sharing.expiry.${preset.key}`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{expiryPreset === "custom" && (
<Input
type="number"
autoFocus
placeholder={t("hosts.sharing.customHoursPlaceholder")}
value={customHours}
onChange={(e) => setCustomHours(e.target.value)}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
)}
<p className="text-[11px] text-muted-foreground leading-snug">
{permissionLevel === "read-only"
? t("sessionSharing.permissionLevel.readOnlyDescription")
: t("sessionSharing.permissionLevel.readWriteDescription")}
</p>
<Button
variant="outline"
className="h-8 shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={
!sessionId ||
submitting ||
(mode === "user" && !selectedUserId) ||
(expiryPreset === "custom" && !expiryHours)
}
onClick={handleCreate}
>
{mode === "link"
? t("sessionSharing.createLinkButton")
: t("sessionSharing.createShareButton")}
</Button>
{createdLink && (
<div className="flex items-center gap-1.5">
<Input readOnly value={createdLink} className="text-xs" />
<Button
variant="outline"
size="icon-sm"
onClick={handleCopyLink}
title={t("sessionSharing.copyLink")}
>
<Copy className="size-3.5" />
</Button>
</div>
)}
<div className="flex flex-col gap-0 border-t border-border pt-2">
<div className="flex items-center gap-1.5 pb-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<Users className="size-3.5" />
{t("sessionSharing.activeShares")}
{shares.length > 0 && (
<span className="text-muted-foreground/40">
({shares.length})
</span>
)}
</div>
<div className="flex flex-col max-h-40 overflow-y-auto">
{shares.length === 0 && (
<div className="px-1 py-4 text-xs text-muted-foreground/50 text-center">
{t("sessionSharing.noActiveShares")}
</div>
)}
{shares.map((share) => {
const targetUser = users.find(
(u) => u.id === share.targetUserId,
);
return (
<div
key={share.id}
className="flex items-center justify-between gap-2 py-2 border-b border-border/60 last:border-0 text-xs"
>
<div className="flex items-center gap-1.5 min-w-0">
{share.shareType === "link" ? (
<Link2 className="size-3 text-muted-foreground shrink-0" />
) : (
<Shield className="size-3 text-muted-foreground shrink-0" />
)}
<div className="flex flex-col min-w-0">
<span className="truncate font-semibold">
{share.shareType === "link"
? t("sessionSharing.linkShareBadge")
: t("sessionSharing.userShareBadge", {
username:
targetUser?.username ??
share.targetUserId ??
"?",
})}
</span>
<span className="text-[10px] text-muted-foreground/60">
{share.permissionLevel === "read-write"
? t("sessionSharing.permissionLevel.readWrite")
: t("sessionSharing.permissionLevel.readOnly")}
{" · "}
{t("sessionSharing.expiresAt", {
date: new Date(share.expiresAt).toLocaleString(),
})}
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10 shrink-0"
onClick={() => handleRevoke(share.id)}
>
{t("sessionSharing.revoke")}
</Button>
</div>
);
})}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,320 @@
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { AlertCircle, Eye } from "lucide-react";
import {
resolveShareLink,
type ResolvedShareLink,
type ShareLinkErrorKind,
} from "@/api/session-sharing-api";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron";
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
const PING_INTERVAL_MS = 30000;
interface TerminalWsMessage {
type: string;
data?: string;
[key: string]: unknown;
}
// Mirrors Terminal.tsx's baseWsUrl construction (dev/electron/embedded/prod).
// Duplicated rather than extracted from that file to avoid touching it here.
// A shared session link is always resolved against the desktop app's
// embedded local backend -- joining a session hosted on someone else's
// remote server isn't supported from the desktop app today.
async function resolveTerminalWsBaseUrl(): Promise<string> {
const isDev =
!isElectron() &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
if (isDev) {
return `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
}
if (isElectron()) {
return "ws://127.0.0.1:30002";
}
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
return `${wsProtocol}://${window.location.host}${getBasePath()}/ssh/websocket/`;
}
function ReadOnlyBadge({ label }: { label: string }) {
return (
<div
className="absolute top-3 right-3 z-20 flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium"
style={{
backgroundColor: "var(--bg-elevated, rgba(0,0,0,0.6))",
color: "var(--foreground)",
border: "1px solid var(--border-base)",
}}
>
<Eye className="size-3.5" />
{label}
</div>
);
}
function CenteredMessage({
icon,
message,
}: {
icon: React.ReactNode;
message: string;
}) {
return (
<div
className="flex flex-col items-center justify-center h-full gap-4 w-full"
style={{ backgroundColor: "var(--bg-base)" }}
>
{icon}
<p
className="text-sm font-semibold text-center max-w-xs"
style={{ color: "var(--foreground)" }}
>
{message}
</p>
</div>
);
}
function GuestTerminalView({
share,
linkToken,
}: {
share: ResolvedShareLink;
linkToken: string;
}) {
const { t } = useTranslation();
const { instance: terminal, ref: xtermRef } = useXTerm();
const [ended, setEnded] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!terminal || !xtermRef.current) return;
terminal.options.theme = { background: "#0c0d0b" };
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(xtermRef.current);
fitAddon.fit();
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
resizeObserver.observe(xtermRef.current);
let cancelled = false;
let ws: WebSocket | null = null;
resolveTerminalWsBaseUrl().then((baseWsUrl) => {
if (cancelled) return;
const separator = baseWsUrl.includes("?") ? "&" : "?";
ws = new WebSocket(
`${baseWsUrl}${separator}shareToken=${encodeURIComponent(linkToken)}`,
);
wsRef.current = ws;
ws.onopen = () => {
pingIntervalRef.current = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }));
}
}, PING_INTERVAL_MS);
};
ws.onmessage = (event) => {
let msg: TerminalWsMessage;
try {
msg = JSON.parse(event.data);
} catch {
return;
}
switch (msg.type) {
case "data":
if (typeof msg.data === "string") terminal.write(msg.data);
break;
case "sessionExpired":
case "sessionTerminatedByOwner":
case "session_ended":
setEnded(t("sessionSharing.guestView.sessionEnded"));
break;
default:
break;
}
};
ws.onclose = () => {
setEnded((prev) => prev ?? t("sessionSharing.guestView.sessionEnded"));
};
if (share.permissionLevel === "read-write") {
terminal.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "input", data }));
}
});
}
});
return () => {
cancelled = true;
resizeObserver.disconnect();
if (pingIntervalRef.current) clearInterval(pingIntervalRef.current);
ws?.close();
wsRef.current = null;
};
// Deliberately runs once terminal mounts - share/token/permission are stable for the view's lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [terminal, linkToken]);
return (
<div className="relative w-full h-full">
{share.permissionLevel === "read-only" && (
<ReadOnlyBadge label={t("sessionSharing.guestView.readOnlyBadge")} />
)}
{ended && (
<div
className="absolute inset-0 z-30 flex items-center justify-center"
style={{ backgroundColor: "var(--bg-base)" }}
>
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={ended}
/>
</div>
)}
<div ref={xtermRef} className="w-full h-full" />
</div>
);
}
function GuestGuacamoleView({ share }: { share: ResolvedShareLink }) {
const { t } = useTranslation();
const [connectionError, setConnectionError] = useState<string | null>(null);
if (!share.connectParams?.token) {
return (
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={t("sessionSharing.guestView.linkInvalid")}
/>
);
}
return (
<div className="relative w-full h-full">
{share.permissionLevel === "read-only" && (
<ReadOnlyBadge label={t("sessionSharing.guestView.readOnlyBadge")} />
)}
{connectionError && (
<div
className="absolute inset-0 z-30 flex items-center justify-center"
style={{ backgroundColor: "var(--bg-base)" }}
>
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={connectionError}
/>
</div>
)}
<GuacamoleDisplay
connectionConfig={{
token: share.connectParams.token,
protocol: share.protocol as "rdp" | "vnc" | "telnet",
type: share.protocol as "rdp" | "vnc" | "telnet",
}}
isVisible={true}
onError={(err) => setConnectionError(err)}
/>
</div>
);
}
export default function SharedSessionView() {
const { t } = useTranslation();
const [share, setShare] = useState<ResolvedShareLink | null>(null);
const [linkToken, setLinkToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (!token) {
setError(t("sessionSharing.guestView.linkInvalid"));
setLoading(false);
return;
}
setLinkToken(token);
resolveShareLink(token)
.then((resolved) => setShare(resolved))
.catch((err) => {
const kind = (err as { kind?: ShareLinkErrorKind })?.kind;
if (kind === "rate-limited") {
setError(t("sessionSharing.guestView.rateLimited"));
} else {
setError(t("sessionSharing.guestView.linkInvalid"));
}
})
.finally(() => setLoading(false));
}, [t]);
return (
<div
className="fixed inset-0 flex flex-col"
style={{ backgroundColor: "var(--bg-base)" }}
>
<div className="relative flex-1 min-h-0">
{loading && (
<SimpleLoader
visible={true}
message={t("sessionSharing.guestView.loading")}
/>
)}
{!loading && error && (
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={error}
/>
)}
{!loading &&
!error &&
share &&
linkToken &&
(share.protocol === "ssh" ? (
<GuestTerminalView share={share} linkToken={linkToken} />
) : (
<GuestGuacamoleView share={share} />
))}
</div>
</div>
);
}
@@ -5,11 +5,13 @@ import {
ChevronDown,
ChevronLeft,
ChevronRight,
Clipboard,
Pencil,
X,
Plus,
RotateCcw,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
@@ -252,6 +254,18 @@ export function MobileTerminalKeyboard({
terminalRef.current?.sendInput?.(seq);
}
async function handlePaste() {
try {
const text = window.electronClipboard
? await window.electronClipboard.readText()
: ((await navigator.clipboard?.readText?.()) ?? "");
if (text) terminalRef.current?.paste?.(text);
else toast.error(t("terminal.clipboardReadFailed"));
} catch {
toast.error(t("terminal.clipboardReadFailed"));
}
}
function toggleCtrl() {
setCtrlActive((v) => !v);
setShiftActive(false);
@@ -322,6 +336,18 @@ export function MobileTerminalKeyboard({
{shiftActive ? t("mobileKeyboard.backTab") : t("mobileKeyboard.tab")}
</button>
{/* Paste */}
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
onPointerDown={(e) => {
e.preventDefault();
handlePaste();
}}
title={t("mobileKeyboard.paste")}
>
<Clipboard className="size-4" />
</button>
<div className={SEP} />
{/* Ctrl */}
+187 -58
View File
@@ -17,16 +17,18 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { useTranslation } from "react-i18next";
import { getBasePath } from "@/lib/base-path";
import {
resolveConnectionOrigin,
buildOriginWsUrl,
} from "@/lib/connection-origin.ts";
import {
getCookie,
isElectron,
isEmbeddedMode,
logActivity,
getSnippets,
deleteCommandFromHistory,
getCommandHistory,
getHostPassword,
getServerConfig,
} from "@/main-axios.ts";
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
@@ -39,7 +41,7 @@ import {
DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS,
} from "@/lib/terminal-themes.ts";
import "./terminal-global-styles.ts";
import { ensureTerminalFontsLoaded } from "./terminal-global-styles.ts";
import { useTheme } from "@/components/theme-provider.tsx";
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
import { useCommandTracker } from "@/features/terminal/command-history/useCommandTracker.ts";
@@ -57,11 +59,19 @@ import { toast } from "sonner";
import { Button } from "@/components/button";
import { Save } from "lucide-react";
import { resolveTermixThemeColors } from "./terminal-theme.ts";
import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
import {
getNextTerminalFontSize,
getTerminalFontZoomDirection,
} from "./terminal-font-zoom.ts";
import {
getUserPreferences,
parseCustomKeybindings,
} from "@/api/open-tabs-api";
import { findMatchingKeybinding } from "@/lib/keybinding-match";
import { dispatchKeybindingAction } from "@/lib/keybinding-dispatch";
import type { CustomKeybinding } from "@/types/keybindings";
export type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
type HostKeyVerificationData = Omit<
@@ -158,12 +168,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
: themeColors.background;
const fitAddonRef = useRef<FitAddon | null>(null);
const webSocketRef = useRef<WebSocket | null>(null);
const customKeybindingsRef = useRef<CustomKeybinding[]>([]);
const cachedSnippetsRef = useRef<{ id: number; content: string }[] | null>(
null,
);
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
const wasDisconnectedBySSH = useRef(false);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const pongReceivedRef = useRef(true);
const pongTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false);
const [isSavingQuickConnect, setIsSavingQuickConnect] = useState(false);
const [isQuickConnectSaved, setIsQuickConnectSaved] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
@@ -182,6 +197,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const [totpRequired, setTotpRequired] = useState(false);
const [totpPrompt, setTotpPrompt] = useState<string>("");
const [isPasswordPrompt, setIsPasswordPrompt] = useState(false);
const [mfaPromptMode, setMfaPromptMode] = useState<
"totp" | "password" | "menu" | "push"
>("totp");
const [mfaWaiting, setMfaWaiting] = useState(false);
const [showAuthDialog, setShowAuthDialog] = useState(false);
const [authDialogReason, setAuthDialogReason] = useState<
"no_keyboard" | "auth_failed" | "timeout"
@@ -512,17 +531,25 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
function handleTotpSubmit(code: string) {
if (webSocketRef.current && code) {
if (totpTimeoutRef.current) {
clearTimeout(totpTimeoutRef.current);
totpTimeoutRef.current = null;
}
const isPushMode = mfaPromptMode === "push";
if (webSocketRef.current && (code || isPushMode)) {
webSocketRef.current.send(
JSON.stringify({
type: isPasswordPrompt ? "password_response" : "totp_response",
data: { code },
}),
);
if (isPushMode) {
// Server blocks until the phone approval completes; keep the dialog
// open in a waiting state rather than closing it immediately. The
// existing timeout continues running until "connected" or "error".
setMfaWaiting(true);
return;
}
if (totpTimeoutRef.current) {
clearTimeout(totpTimeoutRef.current);
totpTimeoutRef.current = null;
}
setTotpRequired(false);
setTotpPrompt("");
setIsPasswordPrompt(false);
@@ -536,6 +563,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
setTotpRequired(false);
setTotpPrompt("");
setIsPasswordPrompt(false);
setMfaPromptMode("totp");
setMfaWaiting(false);
if (onClose) onClose();
}
@@ -824,6 +854,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
webSocketRef.current.send(JSON.stringify({ type: "input", data }));
}
},
paste: (text: string) => {
terminal?.paste(text);
},
notifyResize: () => {
try {
const cols = terminal?.cols ?? undefined;
@@ -846,8 +879,20 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
onOpenFileManager?.("/");
}
},
openShareModal: () => setShareModalOpen(true),
canShare: () =>
isConnected &&
!isQuickConnect &&
!hostConfig.joinShareId &&
typeof hostConfig.id === "number",
}),
[isConnected, terminal],
[
isConnected,
terminal,
isQuickConnect,
hostConfig.joinShareId,
hostConfig.id,
],
);
function getUseRightClickCopyPaste() {
@@ -956,52 +1001,28 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
if (isDev) {
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
} else if (isElectron()) {
let configuredUrl = (window as { configuredServerUrl?: string | null })
.configuredServerUrl;
if (!configuredUrl && !isEmbeddedMode()) {
try {
const serverConfig = await getServerConfig();
configuredUrl = serverConfig?.serverUrl || null;
if (configuredUrl) {
(
window as Window &
typeof globalThis & {
configuredServerUrl?: string | null;
}
).configuredServerUrl = configuredUrl;
}
} catch (error) {
console.error("Failed to resolve Electron server URL:", error);
}
}
if (isEmbeddedMode()) {
baseWsUrl = "ws://127.0.0.1:30002";
const storedJwt = localStorage.getItem("jwt");
if (storedJwt) {
baseWsUrl += `?token=${encodeURIComponent(storedJwt)}`;
}
} else if (!configuredUrl) {
console.error("No configured server URL available for Electron SSH");
const origin = await resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: hostConfig.connectionOrigin as
| "local"
| "remote"
| null
| undefined,
});
const resolvedUrl = await buildOriginWsUrl({
origin,
localPort: 30002,
localPath: "",
remotePath: "/ssh/websocket/",
});
if (!resolvedUrl) {
setIsConnected(false);
setIsConnecting(false);
updateConnectionError(t("errors.failedToLoadServer"));
updateConnectionError(t("errors.remoteServerRequired"));
isConnectingRef.current = false;
return;
} else {
const wsProtocol = configuredUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = configuredUrl
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
baseWsUrl = `${wsProtocol}${wsHost}/ssh/websocket/`;
const storedJwt = localStorage.getItem("jwt");
if (storedJwt) {
baseWsUrl += `?token=${encodeURIComponent(storedJwt)}`;
}
}
baseWsUrl = resolvedUrl;
} else {
baseWsUrl = `${getBasePath()}/ssh/websocket/`;
}
@@ -1076,7 +1097,19 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const restoredSessionId = pendingRestoredSessionIdRef.current;
pendingRestoredSessionIdRef.current = null;
if (restoredSessionId) {
if (hostConfig.joinShareId) {
isAttachingSessionRef.current = true;
ws.send(
JSON.stringify({
type: "joinSharedSession",
data: {
shareId: hostConfig.joinShareId,
tabInstanceId: hostConfig.instanceId,
},
}),
);
} else if (restoredSessionId) {
sessionIdRef.current = restoredSessionId;
isAttachingSessionRef.current = true;
@@ -1327,6 +1360,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
setTotpRequired(true);
setTotpPrompt(msg.prompt || t("terminal.totpCodeLabel"));
setIsPasswordPrompt(false);
setMfaPromptMode("totp");
setMfaWaiting(false);
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
@@ -1342,10 +1377,24 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}, 180000);
} else if (msg.type === "totp_retry") {
// Existing prompt remains visible while the backend asks for another code.
setMfaWaiting(false);
} else if (msg.type === "password_required") {
const promptText: string = msg.prompt || "";
const pushPromptPattern =
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
const isPush = pushPromptPattern.test(promptText);
const isMenu = !isPush && msg.echo === true;
const mode: "menu" | "push" | "password" = isPush
? "push"
: isMenu
? "menu"
: "password";
setTotpRequired(true);
setTotpPrompt(msg.prompt || t("common.password"));
setTotpPrompt(promptText || t("common.password"));
setIsPasswordPrompt(true);
setMfaPromptMode(mode);
setMfaWaiting(false);
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
@@ -1353,12 +1402,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
if (totpTimeoutRef.current) {
clearTimeout(totpTimeoutRef.current);
}
totpTimeoutRef.current = setTimeout(() => {
setTotpRequired(false);
if (webSocketRef.current) {
webSocketRef.current.close();
}
}, 180000);
totpTimeoutRef.current = setTimeout(
() => {
setTotpRequired(false);
if (webSocketRef.current) {
webSocketRef.current.close();
}
},
isPush ? 300000 : 180000,
);
} else if (msg.type === "warpgate_auth_required") {
setWarpgateAuthRequired(true);
setWarpgateAuthUrl(msg.url || "");
@@ -1986,6 +2038,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
(f) => f.value === config.fontFamily,
);
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
ensureTerminalFontsLoaded(fontConfig?.value || TERMINAL_FONTS[0].value);
// Update terminal options individually to avoid re-initialization flashes
terminal.options.cursorBlink = config.cursorBlink;
@@ -2053,6 +2106,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
(f) => f.value === config.fontFamily,
);
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
ensureTerminalFontsLoaded(fontConfig?.value || TERMINAL_FONTS[0].value);
const activeTheme = previewTheme || config.theme;
const themeColors = resolveTermixThemeColors(
@@ -2354,14 +2408,76 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
};
}, [hostConfig.id, hostConfig.instanceId]);
useEffect(() => {
let cancelled = false;
const loadKeybindings = () => {
cachedSnippetsRef.current = null;
getUserPreferences()
.then((prefs) => {
if (!cancelled) {
customKeybindingsRef.current = parseCustomKeybindings(
prefs.customKeybindings,
).filter((kb) => kb.enabled);
}
})
.catch(() => {
// keep previous/empty bindings on failure, non-fatal
});
};
loadKeybindings();
window.addEventListener("customKeybindingsChanged", loadKeybindings);
return () => {
cancelled = true;
window.removeEventListener("customKeybindingsChanged", loadKeybindings);
};
}, []);
useEffect(() => {
if (!terminal) return;
const getSnippetById = async (
id: string,
): Promise<{ content: string } | undefined> => {
try {
if (!cachedSnippetsRef.current) {
cachedSnippetsRef.current = (await getSnippets()) as unknown as {
id: number;
content: string;
}[];
}
const numericId = Number(id);
return cachedSnippetsRef.current?.find((s) => s.id === numericId);
} catch {
return undefined;
}
};
const handleCustomKey = (e: KeyboardEvent): boolean => {
if (e.type !== "keydown") {
return true;
}
// Custom user keybindings take priority over built-in defaults, but
// never override autocomplete popup navigation while it is open.
if (!showAutocompleteRef.current) {
const matched = findMatchingKeybinding(
e,
customKeybindingsRef.current,
);
if (matched) {
e.preventDefault();
e.stopPropagation();
dispatchKeybindingAction(matched.action, {
terminal,
webSocketRef,
writeTextToClipboard,
readTextFromClipboard,
getSnippetById,
});
return false;
}
}
// Forward global app shortcuts to AppShell directly — xterm swallows
// all keydown events and synthetic re-dispatch is unreliable.
// stopPropagation prevents the same event from also firing the window listener.
@@ -2896,6 +3012,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
mode={mfaPromptMode}
waiting={mfaWaiting}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
backgroundColor={backgroundColor}
@@ -3184,6 +3302,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
</div>,
document.body,
)}
{shareModalOpen && typeof hostConfig.id === "number" && (
<ShareSessionModal
open={shareModalOpen}
onClose={() => setShareModalOpen(false)}
hostId={hostConfig.id}
sessionId={sessionIdRef.current}
protocol="ssh"
tabInstanceId={hostConfig.instanceId}
/>
)}
</div>
);
},
@@ -46,6 +46,7 @@ style.innerHTML = `
.xterm .xterm-viewport {
scrollbar-width: thin;
scrollbar-color: rgba(0,0,0,0.3) transparent;
background-color: transparent !important;
}
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb {
@@ -74,3 +75,22 @@ style.innerHTML = `
}
`;
document.head.appendChild(style);
// Canvas fillText() does not reliably trigger @font-face fetches on every
// browser engine (notably Android WebView) the way rendering real DOM text
// does. xterm.js draws glyphs to a <canvas>, so without an explicit load the
// terminal can keep painting the fallback font's tofu boxes even after
// document.fonts.ready resolves. Forcing the load here ensures the glyph
// data is actually fetched before the terminal renders with it.
export function ensureTerminalFontsLoaded(fontFamily: string): void {
if (typeof document === "undefined" || !document.fonts) return;
const specs = [
`400 16px "${fontFamily}"`,
`700 16px "${fontFamily}"`,
`italic 400 16px "${fontFamily}"`,
`italic 700 16px "${fontFamily}"`,
];
for (const spec of specs) {
document.fonts.load(spec).catch(() => {});
}
}
@@ -4,6 +4,9 @@ export interface TerminalHostConfig {
id?: number;
instanceId?: string;
restoredSessionId?: string | null;
/** Set when this tab joins someone else's live shared SSH session instead of connecting/attaching. */
joinSharedSessionId?: string | null;
joinShareId?: string | null;
ip: string;
port: number;
username: string;
@@ -24,7 +27,10 @@ export interface TerminalHandle {
fit: () => void;
focus: () => void;
sendInput: (data: string) => void;
paste: (text: string) => void;
notifyResize: () => void;
refresh: () => void;
getApplicationCursorKeysMode: () => boolean;
openShareModal: () => void;
canShare: () => boolean;
}
+31 -7
View File
@@ -70,6 +70,36 @@ export function PanePreview({
setAttachNonce((n) => n + 1);
}
// For Tailscale-auth hosts the generic pane-preview attach path can fail
// host-reachability checks because the connection is initiated as a plain
// TCP/SSH dial rather than through the Tailscale transport that was used for
// the interactive terminal. Building the hostConfig with an explicit
// authType and a defensively derived SSH port ensures the backend selects
// the correct Tailscale-aware PTY path for both initial attach and reattach.
let terminalHostConfig: TerminalHostConfig;
if (host.authType === "tailscale") {
// Prefer host.sshPort when set (Tailscale SSH can be on a non-standard
// port); fall back to the general host.port.
const resolvedPort = host.sshPort ?? host.port;
terminalHostConfig = {
...host,
port: resolvedPort,
sshPort: resolvedPort,
// Carry authType explicitly to guard against accidental omission
// in the spread (e.g. if host object shape changes upstream).
authType: "tailscale",
instanceId: instanceIdRef.current,
} as TerminalHostConfig;
} else {
const resolvedPort = host.sshPort ?? host.port;
terminalHostConfig = {
...host,
port: resolvedPort,
sshPort: resolvedPort,
instanceId: instanceIdRef.current,
} as TerminalHostConfig;
}
return (
<>
<div className="flex items-center gap-3 border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
@@ -147,13 +177,7 @@ export function PanePreview({
<CommandHistoryProvider key={attachNonce}>
<Terminal
ref={terminalRef}
hostConfig={
{
...host,
sshPort: host.port,
instanceId: instanceIdRef.current,
} as TerminalHostConfig
}
hostConfig={terminalHostConfig}
isVisible={true}
title={pane.sessionName}
showTitle={false}