diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 00000000..e69de29b diff --git a/ci/build.sh b/ci/build.sh index 1930034b..8a6f540c 100755 --- a/ci/build.sh +++ b/ci/build.sh @@ -30,6 +30,7 @@ build(){ if [[ "$(uname)" == "Darwin" ]]; then GOOS=linux build CGO_ENABLED=1 GOOS=darwin build + GOOS=windows GOARCH=386 build exit 0 fi diff --git a/cmd/coder/main.go b/cmd/coder/main.go index 3cf2ec5e..604775b5 100644 --- a/cmd/coder/main.go +++ b/cmd/coder/main.go @@ -6,8 +6,11 @@ import ( _ "net/http/pprof" "os" + "cdr.dev/coder-cli/internal/xterminal" "github.com/spf13/pflag" + "go.coder.com/flog" + "go.coder.com/cli" ) @@ -48,5 +51,12 @@ func main() { log.Println(http.ListenAndServe("localhost:6060", nil)) }() } + + stdoutState, err := xterminal.MakeOutputRaw(os.Stdout.Fd()) + if err != nil { + flog.Fatal("failed to set output to raw: %v", err) + } + defer xterminal.Restore(os.Stdout.Fd(), stdoutState) + cli.RunRoot(&rootCmd{}) } diff --git a/cmd/coder/shell.go b/cmd/coder/shell.go index 644aa5c3..82a31436 100644 --- a/cmd/coder/shell.go +++ b/cmd/coder/shell.go @@ -4,13 +4,11 @@ import ( "context" "io" "os" - "os/signal" "strings" "time" "github.com/spf13/pflag" "golang.org/x/crypto/ssh/terminal" - "golang.org/x/sys/unix" "golang.org/x/time/rate" "golang.org/x/xerrors" "nhooyr.io/websocket" @@ -19,6 +17,7 @@ import ( "go.coder.com/flog" "cdr.dev/coder-cli/internal/activity" + "cdr.dev/coder-cli/internal/xterminal" "cdr.dev/wsep" ) @@ -33,45 +32,8 @@ func (cmd *shellCmd) Spec() cli.CommandSpec { } } -func enableTerminal(fd int) (restore func(), err error) { - state, err := terminal.MakeRaw(fd) - if err != nil { - return restore, xerrors.Errorf("make raw term: %w", err) - } - return func() { - err := terminal.Restore(fd, state) - if err != nil { - flog.Error("restore term state: %v", err) - } - }, nil -} - -func sendResizeEvents(ctx context.Context, termfd int, process wsep.Process) { - sigs := make(chan os.Signal, 16) - signal.Notify(sigs, unix.SIGWINCH) - - // Limit the frequency of resizes to prevent a stuttering effect. - resizeLimiter := rate.NewLimiter(rate.Every(time.Millisecond*100), 1) - - for ctx.Err() == nil { - if ctx.Err() != nil { - return - } - width, height, err := terminal.GetSize(termfd) - if err != nil { - flog.Error("get term size: %v", err) - return - } - - err = process.Resize(ctx, uint16(height), uint16(width)) - if err != nil { - return - } - - // Do this last so the first resize is sent. - <-sigs - resizeLimiter.Wait(ctx) - } +type resizeEvent struct { + height, width uint16 } func (cmd *shellCmd) Run(fl *pflag.FlagSet) { @@ -101,21 +63,40 @@ func (cmd *shellCmd) Run(fl *pflag.FlagSet) { } } +func sendResizeEvents(ctx context.Context, termfd uintptr, process wsep.Process) { + events := xterminal.ResizeEvents(ctx, termfd) + + // Limit the frequency of resizes to prevent a stuttering effect. + resizeLimiter := rate.NewLimiter(rate.Every(time.Millisecond*100), 1) + for { + select { + case newsize := <-events: + err := process.Resize(ctx, newsize.Height, newsize.Width) + if err != nil { + return + } + _ = resizeLimiter.Wait(ctx) + case <-ctx.Done(): + return + } + } +} + func runCommand(ctx context.Context, envName string, command string, args []string) error { var ( entClient = requireAuth() env = findEnv(entClient, envName) ) - termfd := int(os.Stdin.Fd()) + termfd := os.Stdout.Fd() - tty := terminal.IsTerminal(termfd) + tty := terminal.IsTerminal(int(termfd)) if tty { - restore, err := enableTerminal(termfd) + stdinState, err := xterminal.MakeRaw(os.Stdin.Fd()) if err != nil { return err } - defer restore() + defer xterminal.Restore(os.Stdin.Fd(), stdinState) } ctx, cancel := context.WithCancel(ctx) @@ -127,13 +108,22 @@ func runCommand(ctx context.Context, envName string, command string, args []stri } go heartbeat(ctx, conn, 15*time.Second) + var cmdEnv []string + if tty { + term := os.Getenv("TERM") + if term == "" { + term = "xterm" + } + cmdEnv = append(cmdEnv, "TERM="+term) + } + execer := wsep.RemoteExecer(conn) process, err := execer.Start(ctx, wsep.Command{ Command: command, Args: args, TTY: tty, Stdin: true, - Env: []string{"TERM=" + os.Getenv("TERM")}, + Env: cmdEnv, }) if err != nil { return err diff --git a/go.mod b/go.mod index 3fed388a..7d8feec2 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module cdr.dev/coder-cli go 1.14 require ( - cdr.dev/wsep v0.0.0-20200616212001-0613cfe9a4ac + cdr.dev/wsep v0.0.0-20200728013649-82316a09813f github.com/fatih/color v1.9.0 // indirect github.com/gorilla/websocket v1.4.1 github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f diff --git a/go.sum b/go.sum index e687d95d..37756a28 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,7 @@ cdr.dev/slog v1.3.0 h1:MYN1BChIaVEGxdS7I5cpdyMC0+WfJfK8BETAfzfLUGQ= cdr.dev/slog v1.3.0/go.mod h1:C5OL99WyuOK8YHZdYY57dAPN1jK2WJlCdq2VP6xeQns= -cdr.dev/wsep v0.0.0-20200615020153-e2b1c576fc40 h1:f369880iSAZ3cXwvbdc9WIyy3FZ4yanusYZjaVHeis4= -cdr.dev/wsep v0.0.0-20200615020153-e2b1c576fc40/go.mod h1:2VKClUml3gfmLez0gBxTJIjSKszpQotc2ZqPdApfK/Y= -cdr.dev/wsep v0.0.0-20200616212001-0613cfe9a4ac h1:rl4O0qfxgNRWBUe5gQu4of2cdsclcpjGYmLQhSCHX7c= -cdr.dev/wsep v0.0.0-20200616212001-0613cfe9a4ac/go.mod h1:2VKClUml3gfmLez0gBxTJIjSKszpQotc2ZqPdApfK/Y= +cdr.dev/wsep v0.0.0-20200728013649-82316a09813f h1:WnTUINBwXE11xjp5nTVt+H2qB2/KEymos1jKMcppG9U= +cdr.dev/wsep v0.0.0-20200728013649-82316a09813f/go.mod h1:2VKClUml3gfmLez0gBxTJIjSKszpQotc2ZqPdApfK/Y= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -240,8 +238,6 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200610111108-226ff32320da h1:bGb80FudwxpeucJUjPYJXuJ8Hk91vNtfvrymzwiei38= -golang.org/x/sys v0.0.0-20200610111108-226ff32320da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1 h1:ogLJMz+qpzav7lGMh10LMvAkM/fAoGlaiiHYiFYdm80= golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/xterminal/doc.go b/internal/xterminal/doc.go new file mode 100644 index 00000000..21e0ae0e --- /dev/null +++ b/internal/xterminal/doc.go @@ -0,0 +1,13 @@ +// Package xterminal provides functions to change termios or console attributes +// and restore them later on. It supports Unix and Windows. +// +// This does the same thing as x/crypto/ssh/terminal on Linux. On Windows, it +// sets the same console modes as the terminal package but also sets +// `ENABLE_VIRTUAL_TERMINAL_INPUT` and `ENABLE_VIRTUAL_TERMINAL_PROCESSING` to +// allow for VT100 sequences in the console. This is important, otherwise Linux +// apps (with colors or ncurses) that are run through SSH or wsep get +// garbled in a Windows console. +// +// More details can be found out about Windows console modes here: +// https://docs.microsoft.com/en-us/windows/console/setconsolemode +package xterminal diff --git a/internal/xterminal/terminal.go b/internal/xterminal/terminal.go new file mode 100644 index 00000000..d2838725 --- /dev/null +++ b/internal/xterminal/terminal.go @@ -0,0 +1,71 @@ +// +build !windows + +package xterminal + +import ( + "context" + "os" + "os/signal" + + "golang.org/x/crypto/ssh/terminal" + "golang.org/x/sys/unix" +) + +// State differs per-platform. +type State struct { + s *terminal.State +} + +// MakeRaw sets the terminal to raw. +func MakeRaw(fd uintptr) (*State, error) { + s, err := terminal.MakeRaw(int(fd)) + return &State{s}, err +} + +// MakeOutputRaw does nothing on non-Windows platforms. +func MakeOutputRaw(fd uintptr) (*State, error) { + return nil, nil +} + +// Restore terminal back to original state. +func Restore(fd uintptr, state *State) error { + if state == nil { + return nil + } + + return terminal.Restore(int(fd), state.s) +} + +// ColorEnabled returns true on Linux if handle is a terminal. +func ColorEnabled(fd uintptr) (bool, error) { + return terminal.IsTerminal(int(fd)), nil +} + +type ResizeEvent struct { + Height, Width uint16 +} + +// ResizeEvents sends terminal resize events +func ResizeEvents(ctx context.Context, termfd uintptr) chan ResizeEvent { + sigs := make(chan os.Signal, 16) + signal.Notify(sigs, unix.SIGWINCH) + + events := make(chan ResizeEvent) + + go func() { + for ctx.Err() == nil { + width, height, err := terminal.GetSize(int(termfd)) + if err != nil { + return + } + events <- ResizeEvent{ + Height: uint16(height), + Width: uint16(width), + } + + <-sigs + } + }() + + return events +} diff --git a/internal/xterminal/terminal_windows.go b/internal/xterminal/terminal_windows.go new file mode 100644 index 00000000..a7e9eaef --- /dev/null +++ b/internal/xterminal/terminal_windows.go @@ -0,0 +1,118 @@ +// +build windows + +package xterminal + +import ( + "context" + "time" + + "golang.org/x/crypto/ssh/terminal" + "golang.org/x/sys/windows" +) + +// State differs per-platform. +type State struct { + mode uint32 +} + +func makeRaw(handle windows.Handle, input bool) (uint32, error) { + var st uint32 + if err := windows.GetConsoleMode(handle, &st); err != nil { + return 0, err + } + + var raw uint32 + if input { + raw = st &^ (windows.ENABLE_ECHO_INPUT | windows.ENABLE_PROCESSED_INPUT | windows.ENABLE_LINE_INPUT | windows.ENABLE_PROCESSED_OUTPUT) + raw |= windows.ENABLE_VIRTUAL_TERMINAL_INPUT + } else { + raw = st | windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING + } + + if err := windows.SetConsoleMode(handle, raw); err != nil { + return 0, err + } + return st, nil +} + +// MakeRaw sets an input terminal to raw and enables VT100 processing. +func MakeRaw(handle uintptr) (*State, error) { + inSt, err := makeRaw(windows.Handle(handle), true) + if err != nil { + return nil, err + } + + return &State{inSt}, nil +} + +// MakeOutputRaw sets an output terminal to raw and enables VT100 processing. +func MakeOutputRaw(handle uintptr) (*State, error) { + outSt, err := makeRaw(windows.Handle(handle), false) + if err != nil { + return nil, err + } + + return &State{outSt}, nil +} + +// Restore terminal back to original state. +func Restore(handle uintptr, state *State) error { + return windows.SetConsoleMode(windows.Handle(handle), state.mode) +} + +// ColorEnabled returns true if VT100 processing is enabled on the output +// console. +func ColorEnabled(handle uintptr) (bool, error) { + var st uint32 + if err := windows.GetConsoleMode(windows.Handle(handle), &st); err != nil { + return false, err + } + + return st&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0, nil +} + +type ResizeEvent struct { + Height, Width uint16 +} + +func (s ResizeEvent) equal(s2 *ResizeEvent) bool { + if s2 == nil { + return false + } + return s.Height == s2.Height && s.Width == s2.Width +} + +// ResizeEvents sends terminal resize events when the dimensions change. +// Windows does not have a unix.SIGWINCH equivalent, so we poll the terminal size +// at a fixed interval +func ResizeEvents(ctx context.Context, termfd uintptr) chan ResizeEvent { + events := make(chan ResizeEvent) + ticker := time.NewTicker(time.Millisecond * 100) + + go func() { + defer ticker.Stop() + var lastEvent *ResizeEvent + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + width, height, err := terminal.GetSize(int(windows.Handle(termfd))) + if err != nil { + return + } + event := ResizeEvent{ + Height: uint16(height), + Width: uint16(width), + } + if !event.equal(lastEvent) { + events <- event + } + lastEvent = &event + } + } + }() + + return events +}