mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
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:
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
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user