From 9a2171e4eaba2972928ee6743e0fdcbfb4cb0e46 Mon Sep 17 00:00:00 2001 From: Mihai Parparita Date: Mon, 1 Aug 2022 17:41:55 -0700 Subject: [PATCH] cmd/tsconnect: make terminal resizable Makes the terminal container DOM node as large as the window (except for the header) via flexbox. The xterm.js terminal is then sized to fit via xterm-addon-fit. Once we have a computed rows/columns size, and we can tell the SSH session of the computed size. Required introducing an IPNSSHSession type to allow the JS to control the SSH session once opened. That alse allows us to programatically close it, which we do when the user closes the window with the session still active. I initially wanted to open the terminal in a new window instead (so that it could be resizable independently of the main window), but xterm.js does not appear to work well in that mode (possibly because it adds an IntersectionObserver to pause rendering when the window is not visible, and it ends up doing that when the parent window is hidden -- see xtermjs/xterm.js@87dca56dee8018a17b5cb22ec844b7013629da63) Fixes #5150 Signed-off-by: Mihai Parparita --- cmd/tsconnect/index.html | 45 ++++++++++++++------------ cmd/tsconnect/package.json | 3 +- cmd/tsconnect/src/index.css | 4 +++ cmd/tsconnect/src/index.ts | 4 +++ cmd/tsconnect/src/login.ts | 3 +- cmd/tsconnect/src/notifier.ts | 2 +- cmd/tsconnect/src/ssh.ts | 44 +++++++++++++++++-------- cmd/tsconnect/src/wasm_js.ts | 7 +++- cmd/tsconnect/wasm/wasm_js.go | 61 ++++++++++++++++++++++++++++------- cmd/tsconnect/yarn.lock | 5 +++ 10 files changed, 129 insertions(+), 49 deletions(-) diff --git a/cmd/tsconnect/index.html b/cmd/tsconnect/index.html index 05562071b..ba9a530f6 100644 --- a/cmd/tsconnect/index.html +++ b/cmd/tsconnect/index.html @@ -5,33 +5,38 @@ - -
+ +

Tailscale Connect

Loading…
- - diff --git a/cmd/tsconnect/package.json b/cmd/tsconnect/package.json index 770604799..7f09527ff 100644 --- a/cmd/tsconnect/package.json +++ b/cmd/tsconnect/package.json @@ -8,7 +8,8 @@ "qrcode": "^1.5.0", "tailwindcss": "^3.1.6", "typescript": "^4.7.4", - "xterm": "^4.18.0" + "xterm": "^4.18.0", + "xterm-addon-fit": "^0.5.0" }, "scripts": { "lint": "tsc --noEmit" diff --git a/cmd/tsconnect/src/index.css b/cmd/tsconnect/src/index.css index 24dd0cb64..c0ad0b037 100644 --- a/cmd/tsconnect/src/index.css +++ b/cmd/tsconnect/src/index.css @@ -73,3 +73,7 @@ background-color: currentColor; clip-path: polygon(100% 0%, 0 0%, 50% 100%); } + +body.ssh-active #ssh-form { + @apply hidden; +} diff --git a/cmd/tsconnect/src/index.ts b/cmd/tsconnect/src/index.ts index c9fac4170..8716a69bb 100644 --- a/cmd/tsconnect/src/index.ts +++ b/cmd/tsconnect/src/index.ts @@ -52,3 +52,7 @@ function handleGoPanic(err?: string) { } let panicNode: HTMLDivElement | undefined + +export function getContentNode(): HTMLDivElement { + return document.querySelector("#content") as HTMLDivElement +} diff --git a/cmd/tsconnect/src/login.ts b/cmd/tsconnect/src/login.ts index 137121333..5431cab44 100644 --- a/cmd/tsconnect/src/login.ts +++ b/cmd/tsconnect/src/login.ts @@ -3,6 +3,7 @@ // license that can be found in the LICENSE file. import * as qrcode from "qrcode" +import { getContentNode } from "./index" export async function showLoginURL(url: string) { if (loginNode) { @@ -30,7 +31,7 @@ export async function showLoginURL(url: string) { linkNode.appendChild(document.createTextNode(url)) - document.body.appendChild(loginNode) + getContentNode().appendChild(loginNode) } export function hideLoginURL() { diff --git a/cmd/tsconnect/src/notifier.ts b/cmd/tsconnect/src/notifier.ts index 00a7d52e4..a5ce3ffca 100644 --- a/cmd/tsconnect/src/notifier.ts +++ b/cmd/tsconnect/src/notifier.ts @@ -47,7 +47,7 @@ export function notifyState(ipn: IPN, state: IPNState) { showLogoutButton(ipn) break } - const stateNode = document.getElementById("state") as HTMLDivElement + const stateNode = document.querySelector("#state") as HTMLDivElement stateNode.textContent = stateLabel ?? "" } diff --git a/cmd/tsconnect/src/ssh.ts b/cmd/tsconnect/src/ssh.ts index e2415431e..1bdaf9307 100644 --- a/cmd/tsconnect/src/ssh.ts +++ b/cmd/tsconnect/src/ssh.ts @@ -3,10 +3,12 @@ // license that can be found in the LICENSE file. import { Terminal } from "xterm" +import { FitAddon } from "xterm-addon-fit" +import { getContentNode } from "./index" export function showSSHForm(peers: IPNNetMapPeerNode[], ipn: IPN) { - const formNode = document.getElementById("ssh-form") as HTMLDivElement - const noSSHNode = document.getElementById("no-ssh") as HTMLDivElement + const formNode = document.querySelector("#ssh-form") as HTMLDivElement + const noSSHNode = document.querySelector("#no-ssh") as HTMLDivElement const sshPeers = peers.filter( (p) => p.tailscaleSSHEnabled && p.online !== false @@ -39,26 +41,23 @@ export function showSSHForm(peers: IPNNetMapPeerNode[], ipn: IPN) { } export function hideSSHForm() { - const formNode = document.getElementById("ssh-form") as HTMLDivElement + const formNode = document.querySelector("#ssh-form") as HTMLDivElement formNode.classList.add("hidden") } function ssh(hostname: string, username: string, ipn: IPN) { + document.body.classList.add("ssh-active") const termContainerNode = document.createElement("div") - termContainerNode.className = "p-3" - document.body.appendChild(termContainerNode) + termContainerNode.className = "flex-grow bg-black p-2 overflow-hidden" + getContentNode().appendChild(termContainerNode) const term = new Terminal({ cursorBlink: true, }) + const fitAddon = new FitAddon() + term.loadAddon(fitAddon) term.open(termContainerNode) - - // Cancel wheel events from scrolling the page if the terminal has scrollback - termContainerNode.addEventListener("wheel", (e) => { - if (term.buffer.active.baseY > 0) { - e.preventDefault() - } - }) + fitAddon.fit() let onDataHook: ((data: string) => void) | undefined term.onData((e) => { @@ -67,14 +66,33 @@ function ssh(hostname: string, username: string, ipn: IPN) { term.focus() - ipn.ssh(hostname, username, { + const sshSession = ipn.ssh(hostname, username, { writeFn: (input) => term.write(input), setReadFn: (hook) => (onDataHook = hook), rows: term.rows, cols: term.cols, onDone: () => { + resizeObserver.disconnect() term.dispose() termContainerNode.remove() + document.body.classList.remove("ssh-active") + window.removeEventListener("beforeunload", beforeUnloadListener) }, }) + + // Make terminal and SSH session track the size of the containing DOM node. + const resizeObserver = new ResizeObserver((entries) => { + fitAddon.fit() + }) + resizeObserver.observe(termContainerNode) + term.onResize(({ rows, cols }) => { + sshSession.resize(rows, cols) + }) + + // Close the session if the user closes the window without an explicit + // exit. + const beforeUnloadListener = () => { + sshSession.close() + } + window.addEventListener("beforeunload", beforeUnloadListener) } diff --git a/cmd/tsconnect/src/wasm_js.ts b/cmd/tsconnect/src/wasm_js.ts index 32b9ff34b..4ef1b9d09 100644 --- a/cmd/tsconnect/src/wasm_js.ts +++ b/cmd/tsconnect/src/wasm_js.ts @@ -25,7 +25,12 @@ declare global { cols: number onDone: () => void } - ): void + ): IPNSSHSession + } + + interface IPNSSHSession { + resize(rows: number, cols: number): boolean + close(): boolean } interface IPNStateStorage { diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index e47e22a61..bcb777e67 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -142,11 +142,10 @@ func newIPN(jsConfig js.Value) map[string]any { log.Printf("Usage: ssh(hostname, userName, termConfig)") return nil } - go jsIPN.ssh( + return jsIPN.ssh( args[0].String(), args[1].String(), args[2]) - return nil }), } } @@ -256,13 +255,42 @@ func (i *jsIPN) logout() { go i.lb.Logout() } -func (i *jsIPN) ssh(host, username string, termConfig js.Value) { - writeFn := termConfig.Get("writeFn") - setReadFn := termConfig.Get("setReadFn") - rows := termConfig.Get("rows").Int() - cols := termConfig.Get("cols").Int() - onDone := termConfig.Get("onDone") +func (i *jsIPN) ssh(host, username string, termConfig js.Value) map[string]any { + jsSSHSession := &jsSSHSession{ + jsIPN: i, + host: host, + username: username, + termConfig: termConfig, + } + + go jsSSHSession.Run() + return map[string]any{ + "close": js.FuncOf(func(this js.Value, args []js.Value) interface{} { + return jsSSHSession.Close() != nil + }), + "resize": js.FuncOf(func(this js.Value, args []js.Value) interface{} { + rows := args[0].Int() + cols := args[1].Int() + return jsSSHSession.Resize(rows, cols) != nil + }), + } +} + +type jsSSHSession struct { + jsIPN *jsIPN + host string + username string + termConfig js.Value + session *ssh.Session +} + +func (s *jsSSHSession) Run() { + writeFn := s.termConfig.Get("writeFn") + setReadFn := s.termConfig.Get("setReadFn") + rows := s.termConfig.Get("rows").Int() + cols := s.termConfig.Get("cols").Int() + onDone := s.termConfig.Get("onDone") defer onDone.Invoke() write := func(s string) { @@ -274,7 +302,7 @@ func (i *jsIPN) ssh(host, username string, termConfig js.Value) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - c, err := i.dialer.UserDial(ctx, "tcp", net.JoinHostPort(host, "22")) + c, err := s.jsIPN.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.host, "22")) if err != nil { writeError("Dial", err) return @@ -283,10 +311,10 @@ func (i *jsIPN) ssh(host, username string, termConfig js.Value) { config := &ssh.ClientConfig{ HostKeyCallback: ssh.InsecureIgnoreHostKey(), - User: username, + User: s.username, } - sshConn, _, _, err := ssh.NewClientConn(c, host, config) + sshConn, _, _, err := ssh.NewClientConn(c, s.host, config) if err != nil { writeError("SSH Connection", err) return @@ -302,6 +330,7 @@ func (i *jsIPN) ssh(host, username string, termConfig js.Value) { writeError("SSH Session", err) return } + s.session = session write("Session Established\r\n") defer session.Close() @@ -338,11 +367,19 @@ func (i *jsIPN) ssh(host, username string, termConfig js.Value) { err = session.Wait() if err != nil { - writeError("Exit", err) + writeError("Wait", err) return } } +func (s *jsSSHSession) Close() error { + return s.session.Close() +} + +func (s *jsSSHSession) Resize(rows, cols int) error { + return s.session.WindowChange(rows, cols) +} + type termWriter struct { f js.Value } diff --git a/cmd/tsconnect/yarn.lock b/cmd/tsconnect/yarn.lock index c7307bcbc..81c19436b 100644 --- a/cmd/tsconnect/yarn.lock +++ b/cmd/tsconnect/yarn.lock @@ -603,6 +603,11 @@ xtend@^4.0.2: resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== +xterm-addon-fit@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz#2d51b983b786a97dcd6cde805e700c7f913bc596" + integrity sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ== + xterm@^4.18.0: version "4.18.0" resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.18.0.tgz#a1f6ab2c330c3918fb094ae5f4c2562987398ea1"