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
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import {
parseCustomKeybindings,
parseCustomThemes,
} from "../../api/open-tabs-api";
describe("parseCustomKeybindings", () => {
it("parses a valid JSON array", () => {
const raw = JSON.stringify([
{
id: "kb-1",
combo: {
key: "c",
isCode: false,
ctrl: true,
alt: false,
shift: false,
meta: false,
},
action: { type: "copy" },
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
]);
const result = parseCustomKeybindings(raw);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("kb-1");
});
it("returns an empty array for null or undefined input", () => {
expect(parseCustomKeybindings(null)).toEqual([]);
expect(parseCustomKeybindings(undefined)).toEqual([]);
});
it("returns an empty array for malformed JSON", () => {
expect(parseCustomKeybindings("{not json")).toEqual([]);
});
it("returns an empty array when the JSON is not an array", () => {
expect(parseCustomKeybindings(JSON.stringify({ foo: "bar" }))).toEqual([]);
});
});
describe("parseCustomThemes", () => {
it("still parses a valid JSON array (regression check)", () => {
const raw = JSON.stringify([{ id: "t1", name: "My Theme", colors: {} }]);
expect(parseCustomThemes(raw)).toHaveLength(1);
});
});
@@ -0,0 +1,94 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const axiosGetMock = vi.hoisted(() => vi.fn());
vi.mock("axios", () => ({
default: {
get: axiosGetMock,
isAxiosError: (err: unknown): err is { response?: { status?: number } } =>
typeof err === "object" && err !== null && "response" in err,
},
}));
vi.mock("@/lib/base-path", () => ({
getBasePath: () => "",
}));
vi.mock("@/lib/electron", () => ({
isElectron: () => false,
}));
vi.mock("@/main-axios", () => ({
getServerConfig: vi.fn(async () => null),
}));
import {
resolveShareLink,
ShareLinkError,
} from "../../api/session-sharing-api";
beforeEach(() => {
axiosGetMock.mockReset();
});
describe("resolveShareLink", () => {
it("returns the resolved share data on success", async () => {
axiosGetMock.mockResolvedValueOnce({
data: {
protocol: "ssh",
permissionLevel: "read-write",
wsPath: "/terminal/ws?shareToken=abc123",
},
});
const result = await resolveShareLink("abc123");
expect(result).toEqual({
protocol: "ssh",
permissionLevel: "read-write",
wsPath: "/terminal/ws?shareToken=abc123",
});
expect(axiosGetMock).toHaveBeenCalledWith(
expect.stringContaining("/session-sharing/resolve/abc123"),
);
});
it("throws a not-found ShareLinkError on 404", async () => {
axiosGetMock.mockRejectedValueOnce({ response: { status: 404 } });
await expect(resolveShareLink("bad-token")).rejects.toMatchObject({
kind: "not-found",
});
await expect(resolveShareLink("bad-token")).rejects.toBeInstanceOf(
ShareLinkError,
);
});
it("throws a rate-limited ShareLinkError on 429", async () => {
axiosGetMock.mockRejectedValueOnce({ response: { status: 429 } });
await expect(resolveShareLink("token")).rejects.toMatchObject({
kind: "rate-limited",
});
});
it("throws a generic ShareLinkError on unexpected failures", async () => {
axiosGetMock.mockRejectedValueOnce(new Error("network down"));
await expect(resolveShareLink("token")).rejects.toMatchObject({
kind: "unknown",
});
});
it("URL-encodes the link token", async () => {
axiosGetMock.mockResolvedValueOnce({
data: { protocol: "ssh", permissionLevel: "read-only", wsPath: "" },
});
await resolveShareLink("a/b c");
expect(axiosGetMock).toHaveBeenCalledWith(
expect.stringContaining(encodeURIComponent("a/b c")),
);
});
});
@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
render,
screen,
waitFor,
cleanup,
fireEvent,
} from "@testing-library/react";
const api = vi.hoisted(() => ({
createSessionShare: vi.fn(),
getActiveSessionShares: vi.fn(async () => ({ shares: [] })),
revokeSessionShare: vi.fn(async () => ({ success: true as const })),
}));
vi.mock("@/api/session-sharing-api", () => api);
const mainAxios = vi.hoisted(() => ({
getUserList: vi.fn(async () => ({
users: [
{ userId: "u1", username: "alice" },
{ userId: "u2", username: "bob" },
],
})),
}));
vi.mock("@/main-axios", () => mainAxios);
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts ? `${key}:${JSON.stringify(opts)}` : key,
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
Object.assign(navigator, {
clipboard: { writeText: vi.fn(async () => {}) },
});
import { ShareSessionModal } from "../../../features/session-sharing/ShareSessionModal";
beforeEach(() => {
api.createSessionShare.mockReset();
api.getActiveSessionShares.mockReset();
api.getActiveSessionShares.mockResolvedValue({ shares: [] });
api.revokeSessionShare.mockReset();
mainAxios.getUserList.mockClear();
});
afterEach(() => {
cleanup();
});
describe("ShareSessionModal", () => {
it("creates a link share with the default read-only permission and 24h expiry", async () => {
api.createSessionShare.mockResolvedValue({
shareId: "share-1",
linkToken: "tok-123",
expiresAt: "2026-07-21T00:00:00.000Z",
});
render(
<ShareSessionModal
open={true}
onClose={() => {}}
hostId={42}
sessionId="sess-1"
protocol="ssh"
tabInstanceId="tab-1"
/>,
);
const createButton = await screen.findByText(
"sessionSharing.createLinkButton",
);
fireEvent.click(createButton);
await waitFor(() => {
expect(api.createSessionShare).toHaveBeenCalledWith({
hostId: 42,
sessionId: "sess-1",
tabInstanceId: "tab-1",
protocol: "ssh",
shareType: "link",
targetUserId: undefined,
permissionLevel: "read-only",
expiryHours: 24,
});
});
});
it("does not submit a user share until a target user is selected", async () => {
render(
<ShareSessionModal
open={true}
onClose={() => {}}
hostId={7}
sessionId="sess-2"
protocol="rdp"
/>,
);
fireEvent.click(await screen.findByText("sessionSharing.modeTab.user"));
const shareButton = await screen.findByText(
"sessionSharing.createShareButton",
);
expect((shareButton as HTMLButtonElement).disabled).toBe(true);
expect(api.createSessionShare).not.toHaveBeenCalled();
});
it("submits a user share with the selected target and read-write permission", async () => {
api.createSessionShare.mockResolvedValue({
shareId: "share-2",
linkToken: null,
expiresAt: "2026-07-21T00:00:00.000Z",
});
render(
<ShareSessionModal
open={true}
onClose={() => {}}
hostId={7}
sessionId="sess-2"
protocol="vnc"
/>,
);
fireEvent.click(await screen.findByText("sessionSharing.modeTab.user"));
fireEvent.click(await screen.findByText("alice"));
const select = await screen.findByDisplayValue(
"sessionSharing.permissionLevel.readOnly",
);
fireEvent.change(select, { target: { value: "read-write" } });
fireEvent.click(screen.getByText("sessionSharing.createShareButton"));
await waitFor(() => {
expect(api.createSessionShare).toHaveBeenCalledWith({
hostId: 7,
sessionId: "sess-2",
tabInstanceId: undefined,
protocol: "vnc",
shareType: "user",
targetUserId: "u1",
permissionLevel: "read-write",
expiryHours: 24,
});
});
});
it("does not call create when there is no live session id", async () => {
render(
<ShareSessionModal
open={true}
onClose={() => {}}
hostId={1}
sessionId={null}
protocol="ssh"
/>,
);
const createButton = await screen.findByText(
"sessionSharing.createLinkButton",
);
expect((createButton as HTMLButtonElement).disabled).toBe(true);
});
it("loads and renders active shares for the host", async () => {
api.getActiveSessionShares.mockResolvedValue({
shares: [
{
id: "share-x",
hostId: 42,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "sess-1",
tabInstanceId: null,
shareType: "link",
targetUserId: null,
linkToken: "tok-abc",
permissionLevel: "read-only",
createdAt: "2026-07-20T00:00:00.000Z",
expiresAt: "2026-07-21T00:00:00.000Z",
revokedAt: null,
lastJoinedAt: null,
joinCount: 0,
},
],
});
render(
<ShareSessionModal
open={true}
onClose={() => {}}
hostId={42}
sessionId="sess-1"
protocol="ssh"
/>,
);
await waitFor(() => {
expect(screen.getByText("sessionSharing.linkShareBadge")).toBeTruthy();
});
});
});
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
const api = vi.hoisted(() => ({
resolveShareLink: vi.fn(async () => {
throw new Error("resolveShareLink not mocked for this test");
}),
ShareLinkError: class ShareLinkError extends Error {
kind: string;
constructor(message: string, kind: string) {
super(message);
this.kind = kind;
}
},
}));
vi.mock("@/api/session-sharing-api", () => api);
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("react-xtermjs", () => ({
useXTerm: () => ({
instance: {
loadAddon: vi.fn(),
open: vi.fn(),
write: vi.fn(),
onData: vi.fn(),
},
ref: { current: document.createElement("div") },
}),
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class FitAddon {
fit() {}
},
}));
vi.mock("@/features/guacamole/GuacamoleDisplay.tsx", () => ({
GuacamoleDisplay: () => <div data-testid="guacamole-display" />,
}));
import SharedSessionView from "../../../features/session-sharing/SharedSessionView";
function setSearch(search: string) {
window.history.pushState({}, "", `/?${search}`);
}
beforeEach(() => {
api.resolveShareLink.mockReset();
api.resolveShareLink.mockImplementation(async () => {
throw new Error("resolveShareLink not mocked for this test");
});
});
afterEach(() => {
cleanup();
});
describe("SharedSessionView", () => {
it("shows a friendly error when no token is present in the URL", async () => {
setSearch("view=shared");
render(<SharedSessionView />);
await waitFor(() => {
expect(
screen.getByText("sessionSharing.guestView.linkInvalid"),
).toBeTruthy();
});
expect(api.resolveShareLink).not.toHaveBeenCalled();
});
it("shows a generic error when the link is invalid or expired", async () => {
setSearch("view=shared&token=bad");
api.resolveShareLink.mockRejectedValue(
new api.ShareLinkError("not found", "not-found"),
);
render(<SharedSessionView />);
await waitFor(() => {
expect(
screen.getByText("sessionSharing.guestView.linkInvalid"),
).toBeTruthy();
});
});
it("shows a rate-limit specific message on 429", async () => {
setSearch("view=shared&token=abc");
api.resolveShareLink.mockRejectedValue(
new api.ShareLinkError("slow down", "rate-limited"),
);
render(<SharedSessionView />);
await waitFor(() => {
expect(api.resolveShareLink).toHaveBeenCalledWith("abc");
});
await waitFor(() => {
expect(
screen.getByText("sessionSharing.guestView.rateLimited"),
).toBeTruthy();
});
});
it("renders the guacamole display for a resolved rdp share", async () => {
setSearch("view=shared&token=abc");
api.resolveShareLink.mockResolvedValue({
protocol: "rdp",
permissionLevel: "read-only",
wsPath: "/guacamole/websocket/",
connectParams: { token: "guac-join-token" },
});
render(<SharedSessionView />);
await waitFor(() => {
expect(screen.getByTestId("guacamole-display")).toBeTruthy();
});
expect(
screen.getByText("sessionSharing.guestView.readOnlyBadge"),
).toBeTruthy();
});
});
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it } from "vitest";
import { Terminal } from "@xterm/xterm";
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
textarea.value = value;
textarea.selectionStart = value.length;
textarea.selectionEnd = value.length;
}
// iOS Safari/WKWebView reports keyCode 229 for ordinary software-keyboard
// input, not just true IME composition, so xterm routes typing through
// CompositionHelper.keydown -> _handleAnyTextareaChanges instead of the
// normal keypress path. That handler snapshots the textarea value on
// keydown, then diffs it against the value a setTimeout(0) later.
function dispatchIOSKeydown(textarea: HTMLTextAreaElement) {
textarea.dispatchEvent(
new KeyboardEvent("keydown", { keyCode: 229 } as KeyboardEventInit),
);
}
describe("iOS rapid typing (keyCode 229 outside composition)", () => {
let terminal: Terminal | undefined;
let container: HTMLDivElement | undefined;
afterEach(() => {
terminal?.dispose();
container?.remove();
terminal = undefined;
container = undefined;
});
it("forwards a mid-word autocorrect rewrite instead of dropping it", async () => {
container = document.createElement("div");
document.body.appendChild(container);
terminal = new Terminal();
terminal.open(container);
const input: string[] = [];
terminal.onData((data) => input.push(data));
const textarea = terminal.textarea!;
// keydown fires while the textarea still holds the pre-keystroke value;
// the browser (or, on iOS, autocorrect) mutates the value afterward.
// Autocorrect can rewrite characters earlier in the word, not just
// append at the cursor, so the old value is no longer a literal
// substring of the new one.
dispatchIOSKeydown(textarea);
setTextareaValue(textarea, "wrold");
await tick();
dispatchIOSKeydown(textarea);
setTextareaValue(textarea, "world");
await tick();
expect(input.join("")).toBe("wrold" + "orld");
});
});
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
import { ensureTerminalFontsLoaded } from "../../../features/terminal/terminal-global-styles";
describe("ensureTerminalFontsLoaded", () => {
it("requests regular, bold, italic, and bold-italic variants for the given font", () => {
const load = vi.fn().mockResolvedValue([]);
const originalFonts = document.fonts;
Object.defineProperty(document, "fonts", {
configurable: true,
value: { load },
});
try {
ensureTerminalFontsLoaded("Caskaydia Cove Nerd Font Mono");
expect(load).toHaveBeenCalledWith(
'400 16px "Caskaydia Cove Nerd Font Mono"',
);
expect(load).toHaveBeenCalledWith(
'700 16px "Caskaydia Cove Nerd Font Mono"',
);
expect(load).toHaveBeenCalledWith(
'italic 400 16px "Caskaydia Cove Nerd Font Mono"',
);
expect(load).toHaveBeenCalledWith(
'italic 700 16px "Caskaydia Cove Nerd Font Mono"',
);
expect(load).toHaveBeenCalledTimes(4);
} finally {
Object.defineProperty(document, "fonts", {
configurable: true,
value: originalFonts,
});
}
});
it("does not throw when document.fonts is unavailable", () => {
const originalFonts = document.fonts;
Object.defineProperty(document, "fonts", {
configurable: true,
value: undefined,
});
try {
expect(() => ensureTerminalFontsLoaded("JetBrains Mono")).not.toThrow();
} finally {
Object.defineProperty(document, "fonts", {
configurable: true,
value: originalFonts,
});
}
});
it("swallows rejected font load promises", async () => {
const load = vi.fn().mockRejectedValue(new Error("network error"));
const originalFonts = document.fonts;
Object.defineProperty(document, "fonts", {
configurable: true,
value: { load },
});
try {
expect(() => ensureTerminalFontsLoaded("Fira Code")).not.toThrow();
await new Promise((resolve) => setTimeout(resolve, 0));
} finally {
Object.defineProperty(document, "fonts", {
configurable: true,
value: originalFonts,
});
}
});
});
@@ -0,0 +1,88 @@
import { describe, it, expect, afterEach } from "vitest";
import { resolveConnectionOrigin } from "../../lib/connection-origin.js";
const win = window as unknown as Record<string, unknown>;
afterEach(() => {
delete win.IS_ELECTRON;
delete win.electronAPI;
});
describe("resolveConnectionOrigin", () => {
it("always resolves rdp/vnc/telnet to remote, even with a local override", async () => {
win.IS_ELECTRON = true;
for (const connectionType of ["rdp", "vnc", "telnet"]) {
await expect(
resolveConnectionOrigin({ connectionType, connectionOrigin: "local" }),
).resolves.toBe("remote");
}
});
it("always resolves serial to local, even with a remote override", async () => {
win.IS_ELECTRON = true;
await expect(
resolveConnectionOrigin({
connectionType: "serial",
connectionOrigin: "remote",
}),
).resolves.toBe("local");
});
it("resolves to local outside Electron regardless of connectionType", async () => {
await expect(
resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: "remote",
}),
).resolves.toBe("local");
});
it("honors a host-level override for ssh in Electron", async () => {
win.IS_ELECTRON = true;
await expect(
resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: "remote",
}),
).resolves.toBe("remote");
await expect(
resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: "local",
}),
).resolves.toBe("local");
});
it("falls back to the desktop-wide default when no host override is set", async () => {
win.IS_ELECTRON = true;
win.electronAPI = {
invoke: async (channel: string) => {
if (channel === "get-desktop-settings") {
return { defaultConnectionOrigin: "remote" };
}
return null;
},
};
await expect(
resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: null,
}),
).resolves.toBe("remote");
});
it("defaults to local when the desktop settings lookup fails", async () => {
win.IS_ELECTRON = true;
win.electronAPI = {
invoke: async () => {
throw new Error("ipc failed");
},
};
await expect(
resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: null,
}),
).resolves.toBe("local");
});
});
@@ -0,0 +1,106 @@
import { describe, it, expect, vi } from "vitest";
import { dispatchKeybindingAction } from "../../lib/keybinding-dispatch";
import type { Terminal } from "@xterm/xterm";
import type { KeybindingDispatchContext } from "../../lib/keybinding-dispatch";
function makeContext(
overrides: Partial<KeybindingDispatchContext> = {},
): KeybindingDispatchContext & {
sentData: string[];
} {
const sentData: string[] = [];
const ws = {
readyState: 1,
send: vi.fn((raw: string) => {
sentData.push(JSON.parse(raw).data);
}),
};
const ctx: KeybindingDispatchContext & { sentData: string[] } = {
terminal: {
getSelection: vi.fn(() => ""),
clearSelection: vi.fn(),
paste: vi.fn(),
} as unknown as Terminal,
webSocketRef: { current: ws as unknown as WebSocket },
writeTextToClipboard: vi.fn().mockResolvedValue(true),
readTextFromClipboard: vi.fn().mockResolvedValue(""),
getSnippetById: vi.fn().mockResolvedValue(undefined),
sentData,
...overrides,
};
return ctx;
}
describe("dispatchKeybindingAction", () => {
it("copy writes the selection to clipboard and clears it", () => {
const ctx = makeContext();
(ctx.terminal.getSelection as ReturnType<typeof vi.fn>).mockReturnValue(
"hello",
);
dispatchKeybindingAction({ type: "copy" }, ctx);
expect(ctx.writeTextToClipboard).toHaveBeenCalledWith("hello");
expect(ctx.terminal.clearSelection).toHaveBeenCalled();
});
it("copy does nothing when there is no selection", () => {
const ctx = makeContext();
dispatchKeybindingAction({ type: "copy" }, ctx);
expect(ctx.writeTextToClipboard).not.toHaveBeenCalled();
expect(ctx.terminal.clearSelection).not.toHaveBeenCalled();
});
it("paste reads the clipboard and pastes into the terminal", async () => {
const ctx = makeContext({
readTextFromClipboard: vi.fn().mockResolvedValue("pasted text"),
});
dispatchKeybindingAction({ type: "paste" }, ctx);
await Promise.resolve();
await Promise.resolve();
expect(ctx.terminal.paste).toHaveBeenCalledWith("pasted text");
});
it("sendControlCode sends the corresponding control byte", () => {
const ctx = makeContext();
dispatchKeybindingAction(
{ type: "sendControlCode", controlCode: "c" },
ctx,
);
expect(ctx.sentData).toEqual(["\x03"]);
});
it("sendText sends literal text without a trailing return by default", () => {
const ctx = makeContext();
dispatchKeybindingAction({ type: "sendText", text: "ls -la" }, ctx);
expect(ctx.sentData).toEqual(["ls -la"]);
});
it("sendText appends \\r when appendEnter is true", () => {
const ctx = makeContext();
dispatchKeybindingAction(
{ type: "sendText", text: "ls -la", appendEnter: true },
ctx,
);
expect(ctx.sentData).toEqual(["ls -la\r"]);
});
it("runSnippet resolves the snippet and sends its content with a trailing return", async () => {
const ctx = makeContext({
getSnippetById: vi.fn().mockResolvedValue({ content: "uptime" }),
});
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
await Promise.resolve();
await Promise.resolve();
expect(ctx.sentData).toEqual(["uptime\r"]);
});
it("runSnippet does nothing when the snippet no longer exists", async () => {
const ctx = makeContext({
getSnippetById: vi.fn().mockResolvedValue(undefined),
});
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
await Promise.resolve();
await Promise.resolve();
expect(ctx.sentData).toEqual([]);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect } from "vitest";
import {
eventMatchesCombo,
findMatchingKeybinding,
} from "../../lib/keybinding-match";
import type { CustomKeybinding, KeyCombo } from "../../types/keybindings";
function makeEvent(
init: Partial<KeyboardEventInit> & { key: string; code?: string },
) {
return new KeyboardEvent("keydown", {
key: init.key,
code: init.code ?? init.key,
ctrlKey: init.ctrlKey ?? false,
altKey: init.altKey ?? false,
shiftKey: init.shiftKey ?? false,
metaKey: init.metaKey ?? false,
});
}
const ctrlC: KeyCombo = {
key: "c",
isCode: false,
ctrl: true,
alt: false,
shift: false,
meta: false,
};
describe("eventMatchesCombo", () => {
it("matches an exact modifier and key combination", () => {
const e = makeEvent({ key: "c", ctrlKey: true });
expect(eventMatchesCombo(e, ctrlC)).toBe(true);
});
it("does not match when an extra modifier is held", () => {
const e = makeEvent({ key: "c", ctrlKey: true, shiftKey: true });
expect(eventMatchesCombo(e, ctrlC)).toBe(false);
});
it("does not match when a required modifier is missing", () => {
const e = makeEvent({ key: "c" });
expect(eventMatchesCombo(e, ctrlC)).toBe(false);
});
it("compares against e.code when isCode is true", () => {
const combo: KeyCombo = {
key: "BracketRight",
isCode: true,
ctrl: true,
alt: false,
shift: false,
meta: false,
};
const e = makeEvent({ key: "]", code: "BracketRight", ctrlKey: true });
expect(eventMatchesCombo(e, combo)).toBe(true);
});
it("compares against e.key.toLowerCase() when isCode is false", () => {
const e = makeEvent({ key: "C", ctrlKey: true });
expect(eventMatchesCombo(e, ctrlC)).toBe(true);
});
});
describe("findMatchingKeybinding", () => {
function makeBinding(id: string, combo: KeyCombo): CustomKeybinding {
return {
id,
combo,
action: { type: "copy" },
enabled: true,
createdAt: "",
updatedAt: "",
};
}
it("returns the first matching binding", () => {
const bindings = [makeBinding("a", ctrlC), makeBinding("b", ctrlC)];
const e = makeEvent({ key: "c", ctrlKey: true });
expect(findMatchingKeybinding(e, bindings)?.id).toBe("a");
});
it("returns undefined when no bindings match", () => {
const bindings = [makeBinding("a", ctrlC)];
const e = makeEvent({ key: "v", ctrlKey: true });
expect(findMatchingKeybinding(e, bindings)).toBeUndefined();
});
});
@@ -263,6 +263,14 @@ describe("highlightTerminalOutput", () => {
expect(out).toContain(`${ESC}[91mERROR`);
});
it("does not highlight a log-level-like username split across ANSI segments in a colored SSH heading", () => {
// Prompt themes often color the user and host portions of "[user@host]"
// separately, so the heading is not one contiguous plain-text segment.
const chunk = `[${ESC}[1;33mwarning${ESC}[0m@host] some command output`;
const out = highlightTerminalOutput(chunk);
expect(out).toBe(chunk);
});
it("does not highlight 'success' when immediately followed by a path (cd output)", () => {
// Some shells print "success~/new/dir" or "success/path" after a cd command
const out = highlightTerminalOutput("success~/home/user/projects");
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
render,
screen,
waitFor,
cleanup,
fireEvent,
} from "@testing-library/react";
import type { ActiveSessionInfo } from "@/api/open-tabs-api";
const mainAxios = vi.hoisted(() => ({
getActiveSessions: vi.fn(async () => [] as ActiveSessionInfo[]),
deleteOpenTab: vi.fn(async () => {}),
}));
vi.mock("@/main-axios", () => mainAxios);
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts ? `${key}:${JSON.stringify(opts)}` : key,
}),
}));
import { ConnectionsPanel } from "../../sidebar/ConnectionsPanel";
function sharedSession(
overrides: Partial<ActiveSessionInfo> = {},
): ActiveSessionInfo {
return {
sessionId: "sess-shared-1",
hostId: 5,
hostName: "prod-db",
tabInstanceId: null,
isConnected: true,
createdAt: Date.now(),
isOwnSession: false,
sharedByUsername: "alice",
permissionLevel: "read-only",
shareId: "share-1",
...overrides,
};
}
beforeEach(() => {
mainAxios.getActiveSessions.mockReset();
mainAxios.getActiveSessions.mockResolvedValue([]);
mainAxios.deleteOpenTab.mockReset();
});
afterEach(() => {
cleanup();
});
describe("ConnectionsPanel - shared with me", () => {
it("renders a shared-with-me row for sessions the current user does not own", async () => {
mainAxios.getActiveSessions.mockResolvedValue([sharedSession()]);
render(
<ConnectionsPanel
tabs={[]}
activeTabId=""
allHosts={[]}
backgroundTabRecords={[]}
onSwitchToTab={() => {}}
onCloseTab={() => {}}
onReopenTab={() => {}}
onForgetBackground={() => {}}
/>,
);
await waitFor(() => {
expect(screen.getByText("connections.sectionSharedWithMe")).toBeTruthy();
});
expect(screen.getByText("prod-db")).toBeTruthy();
expect(
screen.getByText('connections.sharedBy:{"username":"alice"}'),
).toBeTruthy();
});
it("does not show own sessions in the shared-with-me section", async () => {
mainAxios.getActiveSessions.mockResolvedValue([
sharedSession({
isOwnSession: true,
sharedByUsername: null,
shareId: null,
}),
]);
render(
<ConnectionsPanel
tabs={[]}
activeTabId=""
allHosts={[]}
backgroundTabRecords={[]}
onSwitchToTab={() => {}}
onCloseTab={() => {}}
onReopenTab={() => {}}
onForgetBackground={() => {}}
/>,
);
await waitFor(() => {
expect(mainAxios.getActiveSessions).toHaveBeenCalled();
});
expect(screen.queryByText("connections.sectionSharedWithMe")).toBeNull();
});
it("dispatches onJoinSharedSession with the session when Join is clicked", async () => {
const session = sharedSession();
mainAxios.getActiveSessions.mockResolvedValue([session]);
const onJoinSharedSession = vi.fn();
render(
<ConnectionsPanel
tabs={[]}
activeTabId=""
allHosts={[]}
backgroundTabRecords={[]}
onSwitchToTab={() => {}}
onCloseTab={() => {}}
onReopenTab={() => {}}
onForgetBackground={() => {}}
onJoinSharedSession={onJoinSharedSession}
/>,
);
const joinButton = await screen.findByText("connections.join");
fireEvent.click(joinButton);
expect(onJoinSharedSession).toHaveBeenCalledWith(session);
});
it("shows a read-write badge for read-write shared sessions", async () => {
mainAxios.getActiveSessions.mockResolvedValue([
sharedSession({ permissionLevel: "read-write" }),
]);
render(
<ConnectionsPanel
tabs={[]}
activeTabId=""
allHosts={[]}
backgroundTabRecords={[]}
onSwitchToTab={() => {}}
onCloseTab={() => {}}
onReopenTab={() => {}}
onForgetBackground={() => {}}
/>,
);
await waitFor(() => {
expect(
screen.getByText("sessionSharing.permissionLevel.readWrite"),
).toBeTruthy();
});
});
});
@@ -4,6 +4,7 @@ import {
buildHostEditorPayload,
type HostProtocols,
} from "../../sidebar/HostEditorData";
import type { Host } from "@/types/ui-types";
const sshOnly: HostProtocols = {
enableSsh: true,
@@ -12,6 +13,27 @@ const sshOnly: HostProtocols = {
enableTelnet: false,
};
const rdpOnly: HostProtocols = {
enableSsh: false,
enableRdp: true,
enableVnc: false,
enableTelnet: false,
};
const vncOnly: HostProtocols = {
enableSsh: false,
enableRdp: false,
enableVnc: true,
enableTelnet: false,
};
const telnetOnly: HostProtocols = {
enableSsh: false,
enableRdp: false,
enableVnc: false,
enableTelnet: true,
};
describe("buildHostEditorPayload auth field isolation", () => {
it("only sends the password when authType is password", () => {
const form = {
@@ -135,3 +157,75 @@ describe("buildHostEditorPayload auth field isolation", () => {
expect(tc?.sudoPassword).toBe("sudo-secret");
});
});
describe("RDP/VNC/Telnet password persistence indicator", () => {
it("seeds a sentinel value when the host reports a saved rdp password", () => {
const host = {
hasRdpPassword: true,
rdpAuthType: "direct",
} as Host;
const form = createHostEditorForm(host);
expect(form.rdpPassword).toBe("existing_rdp_password");
});
it("does not send the rdp sentinel back to the backend unchanged", () => {
const host = { hasRdpPassword: true, rdpAuthType: "direct" } as Host;
const form = { ...createHostEditorForm(host) };
const payload = buildHostEditorPayload(form, rdpOnly);
expect(payload.rdpPassword).toBeNull();
});
it("sends a newly typed rdp password", () => {
const host = { hasRdpPassword: true, rdpAuthType: "direct" } as Host;
const form = {
...createHostEditorForm(host),
rdpPassword: "new-rdp-pass",
};
const payload = buildHostEditorPayload(form, rdpOnly);
expect(payload.rdpPassword).toBe("new-rdp-pass");
});
it("seeds a sentinel value when the host reports a saved vnc password", () => {
const host = { hasVncPassword: true, vncAuthType: "direct" } as Host;
const form = createHostEditorForm(host);
expect(form.vncPassword).toBe("existing_vnc_password");
});
it("does not send the vnc sentinel back to the backend unchanged", () => {
const host = { hasVncPassword: true, vncAuthType: "direct" } as Host;
const form = { ...createHostEditorForm(host) };
const payload = buildHostEditorPayload(form, vncOnly);
expect(payload.vncPassword).toBeNull();
});
it("seeds a sentinel value when the host reports a saved telnet password", () => {
const host = {
hasTelnetPassword: true,
telnetAuthType: "direct",
} as Host;
const form = createHostEditorForm(host);
expect(form.telnetPassword).toBe("existing_telnet_password");
});
it("does not send the telnet sentinel back to the backend unchanged", () => {
const host = {
hasTelnetPassword: true,
telnetAuthType: "direct",
} as Host;
const form = { ...createHostEditorForm(host) };
const payload = buildHostEditorPayload(form, telnetOnly);
expect(payload.telnetPassword).toBeNull();
});
});