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@87dca56dee) Fixes #5150 Signed-off-by: Mihai Parparita <mihai@tailscale.com>pull/5276/head
parent
8725b14056
commit
9a2171e4ea
|
@ -5,33 +5,38 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="stylesheet" type="text/css" href="dist/index.css" />
|
<link rel="stylesheet" type="text/css" href="dist/index.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="flex flex-col min-h-screen">
|
<body class="flex flex-col h-screen overflow-hidden">
|
||||||
<div class="bg-gray-100 border-b border-gray-200 pt-4 pb-2 mb-6">
|
<div class="bg-gray-100 border-b border-gray-200 pt-4 pb-2">
|
||||||
<header class="container mx-auto px-4 flex flex-row items-center">
|
<header class="container mx-auto px-4 flex flex-row items-center">
|
||||||
<h1 class="text-3xl font-bold grow">Tailscale Connect</h1>
|
<h1 class="text-3xl font-bold grow">Tailscale Connect</h1>
|
||||||
<div class="text-gray-600" id="state">Loading…</div>
|
<div class="text-gray-600" id="state">Loading…</div>
|
||||||
</header>
|
</header>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<div
|
||||||
id="ssh-form"
|
id="content"
|
||||||
class="container mx-auto px-4 hidden flex justify-center"
|
class="flex-grow flex flex-col justify-center overflow-hidden"
|
||||||
>
|
>
|
||||||
<input type="text" class="input username" placeholder="Username" />
|
<form
|
||||||
<div class="select-with-arrow mx-2">
|
id="ssh-form"
|
||||||
<select class="select"></select>
|
class="container mx-auto px-4 hidden flex justify-center"
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="submit"
|
|
||||||
class="button bg-green-500 border-green-500 text-white hover:bg-green-600 hover:border-green-600"
|
|
||||||
value="SSH"
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
<div id="no-ssh" class="container mx-auto px-4 hidden text-center">
|
|
||||||
None of your machines have
|
|
||||||
<a href="https://tailscale.com/kb/1193/tailscale-ssh/" class="link"
|
|
||||||
>Tailscale SSH</a
|
|
||||||
>
|
>
|
||||||
enabled. Give it a try!
|
<input type="text" class="input username" placeholder="Username" />
|
||||||
|
<div class="select-with-arrow mx-2">
|
||||||
|
<select class="select"></select>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="submit"
|
||||||
|
class="button bg-green-500 border-green-500 text-white hover:bg-green-600 hover:border-green-600"
|
||||||
|
value="SSH"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
<div id="no-ssh" class="container mx-auto px-4 hidden text-center">
|
||||||
|
None of your machines have
|
||||||
|
<a href="https://tailscale.com/kb/1193/tailscale-ssh/" class="link"
|
||||||
|
>Tailscale SSH</a
|
||||||
|
>
|
||||||
|
enabled. Give it a try!
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="dist/index.js"></script>
|
<script src="dist/index.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
@ -8,7 +8,8 @@
|
||||||
"qrcode": "^1.5.0",
|
"qrcode": "^1.5.0",
|
||||||
"tailwindcss": "^3.1.6",
|
"tailwindcss": "^3.1.6",
|
||||||
"typescript": "^4.7.4",
|
"typescript": "^4.7.4",
|
||||||
"xterm": "^4.18.0"
|
"xterm": "^4.18.0",
|
||||||
|
"xterm-addon-fit": "^0.5.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit"
|
||||||
|
|
|
@ -73,3 +73,7 @@
|
||||||
background-color: currentColor;
|
background-color: currentColor;
|
||||||
clip-path: polygon(100% 0%, 0 0%, 50% 100%);
|
clip-path: polygon(100% 0%, 0 0%, 50% 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.ssh-active #ssh-form {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
|
|
@ -52,3 +52,7 @@ function handleGoPanic(err?: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let panicNode: HTMLDivElement | undefined
|
let panicNode: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
export function getContentNode(): HTMLDivElement {
|
||||||
|
return document.querySelector("#content") as HTMLDivElement
|
||||||
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
// license that can be found in the LICENSE file.
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
import * as qrcode from "qrcode"
|
import * as qrcode from "qrcode"
|
||||||
|
import { getContentNode } from "./index"
|
||||||
|
|
||||||
export async function showLoginURL(url: string) {
|
export async function showLoginURL(url: string) {
|
||||||
if (loginNode) {
|
if (loginNode) {
|
||||||
|
@ -30,7 +31,7 @@ export async function showLoginURL(url: string) {
|
||||||
|
|
||||||
linkNode.appendChild(document.createTextNode(url))
|
linkNode.appendChild(document.createTextNode(url))
|
||||||
|
|
||||||
document.body.appendChild(loginNode)
|
getContentNode().appendChild(loginNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hideLoginURL() {
|
export function hideLoginURL() {
|
||||||
|
|
|
@ -47,7 +47,7 @@ export function notifyState(ipn: IPN, state: IPNState) {
|
||||||
showLogoutButton(ipn)
|
showLogoutButton(ipn)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
const stateNode = document.getElementById("state") as HTMLDivElement
|
const stateNode = document.querySelector("#state") as HTMLDivElement
|
||||||
stateNode.textContent = stateLabel ?? ""
|
stateNode.textContent = stateLabel ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,10 +3,12 @@
|
||||||
// license that can be found in the LICENSE file.
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
import { Terminal } from "xterm"
|
import { Terminal } from "xterm"
|
||||||
|
import { FitAddon } from "xterm-addon-fit"
|
||||||
|
import { getContentNode } from "./index"
|
||||||
|
|
||||||
export function showSSHForm(peers: IPNNetMapPeerNode[], ipn: IPN) {
|
export function showSSHForm(peers: IPNNetMapPeerNode[], ipn: IPN) {
|
||||||
const formNode = document.getElementById("ssh-form") as HTMLDivElement
|
const formNode = document.querySelector("#ssh-form") as HTMLDivElement
|
||||||
const noSSHNode = document.getElementById("no-ssh") as HTMLDivElement
|
const noSSHNode = document.querySelector("#no-ssh") as HTMLDivElement
|
||||||
|
|
||||||
const sshPeers = peers.filter(
|
const sshPeers = peers.filter(
|
||||||
(p) => p.tailscaleSSHEnabled && p.online !== false
|
(p) => p.tailscaleSSHEnabled && p.online !== false
|
||||||
|
@ -39,26 +41,23 @@ export function showSSHForm(peers: IPNNetMapPeerNode[], ipn: IPN) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hideSSHForm() {
|
export function hideSSHForm() {
|
||||||
const formNode = document.getElementById("ssh-form") as HTMLDivElement
|
const formNode = document.querySelector("#ssh-form") as HTMLDivElement
|
||||||
formNode.classList.add("hidden")
|
formNode.classList.add("hidden")
|
||||||
}
|
}
|
||||||
|
|
||||||
function ssh(hostname: string, username: string, ipn: IPN) {
|
function ssh(hostname: string, username: string, ipn: IPN) {
|
||||||
|
document.body.classList.add("ssh-active")
|
||||||
const termContainerNode = document.createElement("div")
|
const termContainerNode = document.createElement("div")
|
||||||
termContainerNode.className = "p-3"
|
termContainerNode.className = "flex-grow bg-black p-2 overflow-hidden"
|
||||||
document.body.appendChild(termContainerNode)
|
getContentNode().appendChild(termContainerNode)
|
||||||
|
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
})
|
})
|
||||||
|
const fitAddon = new FitAddon()
|
||||||
|
term.loadAddon(fitAddon)
|
||||||
term.open(termContainerNode)
|
term.open(termContainerNode)
|
||||||
|
fitAddon.fit()
|
||||||
// Cancel wheel events from scrolling the page if the terminal has scrollback
|
|
||||||
termContainerNode.addEventListener("wheel", (e) => {
|
|
||||||
if (term.buffer.active.baseY > 0) {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
let onDataHook: ((data: string) => void) | undefined
|
let onDataHook: ((data: string) => void) | undefined
|
||||||
term.onData((e) => {
|
term.onData((e) => {
|
||||||
|
@ -67,14 +66,33 @@ function ssh(hostname: string, username: string, ipn: IPN) {
|
||||||
|
|
||||||
term.focus()
|
term.focus()
|
||||||
|
|
||||||
ipn.ssh(hostname, username, {
|
const sshSession = ipn.ssh(hostname, username, {
|
||||||
writeFn: (input) => term.write(input),
|
writeFn: (input) => term.write(input),
|
||||||
setReadFn: (hook) => (onDataHook = hook),
|
setReadFn: (hook) => (onDataHook = hook),
|
||||||
rows: term.rows,
|
rows: term.rows,
|
||||||
cols: term.cols,
|
cols: term.cols,
|
||||||
onDone: () => {
|
onDone: () => {
|
||||||
|
resizeObserver.disconnect()
|
||||||
term.dispose()
|
term.dispose()
|
||||||
termContainerNode.remove()
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,12 @@ declare global {
|
||||||
cols: number
|
cols: number
|
||||||
onDone: () => void
|
onDone: () => void
|
||||||
}
|
}
|
||||||
): void
|
): IPNSSHSession
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPNSSHSession {
|
||||||
|
resize(rows: number, cols: number): boolean
|
||||||
|
close(): boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IPNStateStorage {
|
interface IPNStateStorage {
|
||||||
|
|
|
@ -142,11 +142,10 @@ func newIPN(jsConfig js.Value) map[string]any {
|
||||||
log.Printf("Usage: ssh(hostname, userName, termConfig)")
|
log.Printf("Usage: ssh(hostname, userName, termConfig)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
go jsIPN.ssh(
|
return jsIPN.ssh(
|
||||||
args[0].String(),
|
args[0].String(),
|
||||||
args[1].String(),
|
args[1].String(),
|
||||||
args[2])
|
args[2])
|
||||||
return nil
|
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -256,13 +255,42 @@ func (i *jsIPN) logout() {
|
||||||
go i.lb.Logout()
|
go i.lb.Logout()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *jsIPN) ssh(host, username string, termConfig js.Value) {
|
func (i *jsIPN) ssh(host, username string, termConfig js.Value) map[string]any {
|
||||||
writeFn := termConfig.Get("writeFn")
|
jsSSHSession := &jsSSHSession{
|
||||||
setReadFn := termConfig.Get("setReadFn")
|
jsIPN: i,
|
||||||
rows := termConfig.Get("rows").Int()
|
host: host,
|
||||||
cols := termConfig.Get("cols").Int()
|
username: username,
|
||||||
onDone := termConfig.Get("onDone")
|
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()
|
defer onDone.Invoke()
|
||||||
|
|
||||||
write := func(s string) {
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
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 {
|
if err != nil {
|
||||||
writeError("Dial", err)
|
writeError("Dial", err)
|
||||||
return
|
return
|
||||||
|
@ -283,10 +311,10 @@ func (i *jsIPN) ssh(host, username string, termConfig js.Value) {
|
||||||
|
|
||||||
config := &ssh.ClientConfig{
|
config := &ssh.ClientConfig{
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
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 {
|
if err != nil {
|
||||||
writeError("SSH Connection", err)
|
writeError("SSH Connection", err)
|
||||||
return
|
return
|
||||||
|
@ -302,6 +330,7 @@ func (i *jsIPN) ssh(host, username string, termConfig js.Value) {
|
||||||
writeError("SSH Session", err)
|
writeError("SSH Session", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.session = session
|
||||||
write("Session Established\r\n")
|
write("Session Established\r\n")
|
||||||
defer session.Close()
|
defer session.Close()
|
||||||
|
|
||||||
|
@ -338,11 +367,19 @@ func (i *jsIPN) ssh(host, username string, termConfig js.Value) {
|
||||||
|
|
||||||
err = session.Wait()
|
err = session.Wait()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError("Exit", err)
|
writeError("Wait", err)
|
||||||
return
|
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 {
|
type termWriter struct {
|
||||||
f js.Value
|
f js.Value
|
||||||
}
|
}
|
||||||
|
|
|
@ -603,6 +603,11 @@ xtend@^4.0.2:
|
||||||
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
|
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
|
||||||
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
|
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:
|
xterm@^4.18.0:
|
||||||
version "4.18.0"
|
version "4.18.0"
|
||||||
resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.18.0.tgz#a1f6ab2c330c3918fb094ae5f4c2562987398ea1"
|
resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.18.0.tgz#a1f6ab2c330c3918fb094ae5f4c2562987398ea1"
|
||||||
|
|
Loading…
Reference in New Issue