From 90e01fa655f6a6c25fdcd8d4d821284805d49a60 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 9 Jan 2024 10:53:55 -0500 Subject: [PATCH 1/4] feat(term): add term/conpty Add support for windows conpty https://learn.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session --- go.work | 1 + go.work.sum | 8 +- term/conpty/conpty_windows.go | 271 ++++++++++++++++++++++++++++++++++ term/conpty/doc.go | 5 + term/conpty/exec_windows.go | 214 +++++++++++++++++++++++++++ term/go.mod | 5 + term/go.sum | 2 + 7 files changed, 500 insertions(+), 6 deletions(-) create mode 100644 term/conpty/conpty_windows.go create mode 100644 term/conpty/doc.go create mode 100644 term/conpty/exec_windows.go create mode 100644 term/go.mod create mode 100644 term/go.sum diff --git a/go.work b/go.work index fec2936a..06c4cdd3 100644 --- a/go.work +++ b/go.work @@ -7,4 +7,5 @@ use ( ./exp/slice ./exp/strings ./exp/teatest + ./term ) diff --git a/go.work.sum b/go.work.sum index 16dcc9ec..563b6671 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,6 +1,2 @@ -github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= -github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/term/conpty/conpty_windows.go b/term/conpty/conpty_windows.go new file mode 100644 index 00000000..86a05c52 --- /dev/null +++ b/term/conpty/conpty_windows.go @@ -0,0 +1,271 @@ +package conpty + +import ( + "errors" + "fmt" + "io" + "os" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Default size. +const ( + DefaultWidth = 80 + DefaultHeight = 25 +) + +// ConPty represents a Windows Console Pseudo-terminal. +// https://learn.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session#preparing-the-communication-channels +type ConPty struct { + hpc *windows.Handle + inPipeFd, outPipeFd windows.Handle + inPipe, outPipe *os.File + attrList *windows.ProcThreadAttributeListContainer + size windows.Coord +} + +var ( + _ io.Writer = &ConPty{} + _ io.Reader = &ConPty{} +) + +// New creates a new ConPty device. +// Accepts a custom width, height, and flags that will get passed to +// windows.CreatePseudoConsole. +func New(w int, h int, flags int) (c *ConPty, err error) { + if w <= 0 { + w = DefaultWidth + } + if h <= 0 { + h = DefaultHeight + } + + c = &ConPty{ + hpc: new(windows.Handle), + size: windows.Coord{ + X: int16(w), Y: int16(h), + }, + } + + var ptyIn, ptyOut windows.Handle + if err := windows.CreatePipe(&ptyIn, &c.inPipeFd, nil, 0); err != nil { + return nil, fmt.Errorf("failed to create pipes for pseudo console: %w", err) + } + + if err := windows.CreatePipe(&c.outPipeFd, &ptyOut, nil, 0); err != nil { + return nil, fmt.Errorf("failed to create pipes for pseudo console: %w", err) + } + + if err := windows.CreatePseudoConsole(c.size, ptyIn, ptyOut, uint32(flags), c.hpc); err != nil { + return nil, fmt.Errorf("failed to create pseudo console: %w", err) + } + + // We don't need the pty pipes anymore, these will get dup'd when the + // new process starts. + if err := windows.CloseHandle(ptyOut); err != nil { + return nil, fmt.Errorf("failed to close pseudo console handle: %w", err) + } + if err := windows.CloseHandle(ptyIn); err != nil { + return nil, fmt.Errorf("failed to close pseudo console handle: %w", err) + } + + c.inPipe = os.NewFile(uintptr(c.inPipeFd), "|0") + c.outPipe = os.NewFile(uintptr(c.outPipeFd), "|1") + + // Allocate an attribute list that's large enough to do the operations we care about + // 1. Pseudo console setup + c.attrList, err = windows.NewProcThreadAttributeList(1) + if err != nil { + return nil, err + } + + if err := c.attrList.Update( + windows.PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + unsafe.Pointer(*c.hpc), + unsafe.Sizeof(*c.hpc), + ); err != nil { + return nil, fmt.Errorf("failed to update proc thread attributes for pseudo console: %w", err) + } + + return +} + +// Handle returns the ConPty handle. +func (p *ConPty) Handle() windows.Handle { + return *p.hpc +} + +// Close closes the ConPty device. +func (p *ConPty) Close() error { + if p.attrList != nil { + p.attrList.Delete() + } + + windows.ClosePseudoConsole(*p.hpc) + return errors.Join(p.inPipe.Close(), p.outPipe.Close()) +} + +// InPipe returns the ConPty input pipe. +func (p *ConPty) InPipe() *os.File { + return p.inPipe +} + +// OutPipe returns the ConPty output pipe. +func (p *ConPty) OutPipe() *os.File { + return p.outPipe +} + +// Write safely writes bytes to the ConPty. +func (c *ConPty) Write(p []byte) (n int, err error) { + var l uint32 + err = windows.WriteFile(c.inPipeFd, p, &l, nil) + return int(l), err +} + +// Read safely reads bytes from the ConPty. +func (c *ConPty) Read(p []byte) (n int, err error) { + var l uint32 + err = windows.ReadFile(c.outPipeFd, p, &l, nil) + return int(l), err +} + +// Resize resizes the pseudo-console. +func (c *ConPty) Resize(w int, h int) error { + size := windows.Coord{X: int16(w), Y: int16(h)} + if err := windows.ResizePseudoConsole(*c.hpc, size); err != nil { + return fmt.Errorf("failed to resize pseudo console: %w", err) + } + c.size = size + return nil +} + +// Size returns the current pseudo-console size. +func (c *ConPty) Size() (w int, h int) { + w = int(c.size.X) + h = int(c.size.Y) + return +} + +var zeroAttr syscall.ProcAttr + +// Spawn creates a new process attached to the pseudo-console. +func (c *ConPty) Spawn(name string, args []string, attr *syscall.ProcAttr) (pid int, handle windows.Handle, err error) { + if attr == nil { + attr = &zeroAttr + } + + argv0, err := lookExtensions(name, attr.Dir) + if err != nil { + return 0, 0, err + } + if len(attr.Dir) != 0 { + // Windows CreateProcess looks for argv0 relative to the current + // directory, and, only once the new process is started, it does + // Chdir(attr.Dir). We are adjusting for that difference here by + // making argv0 absolute. + var err error + argv0, err = joinExeDirAndFName(attr.Dir, argv0) + if err != nil { + return 0, 0, err + } + } + + argv0p, err := windows.UTF16PtrFromString(argv0) + if err != nil { + return 0, 0, err + } + + var cmdline string + if attr.Sys != nil && attr.Sys.CmdLine != "" { + cmdline = attr.Sys.CmdLine + } else { + cmdline = windows.ComposeCommandLine(args) + } + argvp, err := windows.UTF16PtrFromString(cmdline) + if err != nil { + return 0, 0, err + } + + var dirp *uint16 + if len(attr.Dir) != 0 { + dirp, err = windows.UTF16PtrFromString(attr.Dir) + if err != nil { + return 0, 0, err + } + } + + if attr.Env == nil { + attr.Env, err = execEnvDefault(attr.Sys) + if err != nil { + return 0, 0, err + } + } + + siEx := new(windows.StartupInfoEx) + siEx.Flags = windows.STARTF_USESTDHANDLES + + pi := new(windows.ProcessInformation) + + // Need EXTENDED_STARTUPINFO_PRESENT as we're making use of the attribute list field. + flags := uint32(windows.CREATE_UNICODE_ENVIRONMENT) | windows.EXTENDED_STARTUPINFO_PRESENT + if attr.Sys != nil && attr.Sys.CreationFlags != 0 { + flags |= attr.Sys.CreationFlags + } + + var zeroSec windows.SecurityAttributes + pSec := &windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(zeroSec)), InheritHandle: 1} + if attr.Sys != nil && attr.Sys.ProcessAttributes != nil { + pSec = &windows.SecurityAttributes{ + Length: attr.Sys.ProcessAttributes.Length, + InheritHandle: attr.Sys.ProcessAttributes.InheritHandle, + } + } + tSec := &windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(zeroSec)), InheritHandle: 1} + if attr.Sys != nil && attr.Sys.ThreadAttributes != nil { + tSec = &windows.SecurityAttributes{ + Length: attr.Sys.ThreadAttributes.Length, + InheritHandle: attr.Sys.ThreadAttributes.InheritHandle, + } + } + + siEx.ProcThreadAttributeList = c.attrList.List() //nolint:govet // unusedwrite: ProcThreadAttributeList will be read in syscall + siEx.Cb = uint32(unsafe.Sizeof(*siEx)) + if attr.Sys != nil && attr.Sys.Token != 0 { + err = windows.CreateProcessAsUser( + windows.Token(attr.Sys.Token), + argv0p, + argvp, + pSec, + tSec, + false, + flags, + createEnvBlock(addCriticalEnv(dedupEnvCase(true, attr.Env))), + dirp, + &siEx.StartupInfo, + pi, + ) + } else { + err = windows.CreateProcess( + argv0p, + argvp, + pSec, + tSec, + false, + flags, + createEnvBlock(addCriticalEnv(dedupEnvCase(true, attr.Env))), + dirp, + &siEx.StartupInfo, + pi, + ) + } + if err != nil { + return 0, 0, fmt.Errorf("failed to create process: %w", err) + } + + defer windows.CloseHandle(pi.Thread) + + return int(pi.ProcessId), pi.Process, nil +} diff --git a/term/conpty/doc.go b/term/conpty/doc.go new file mode 100644 index 00000000..49dc1f5b --- /dev/null +++ b/term/conpty/doc.go @@ -0,0 +1,5 @@ +// Package conpty implements Windows Console Pseudo-terminal support. +// +// https://learn.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session + +package conpty diff --git a/term/conpty/exec_windows.go b/term/conpty/exec_windows.go new file mode 100644 index 00000000..43d89840 --- /dev/null +++ b/term/conpty/exec_windows.go @@ -0,0 +1,214 @@ +package conpty + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "unicode/utf16" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Below are a bunch of helpers for working with Windows' CreateProcess family +// of functions. These are mostly exact copies of the same utilities found in +// the go stdlib. + +func lookExtensions(path, dir string) (string, error) { + if filepath.Base(path) == path { + path = filepath.Join(".", path) + } + + if dir == "" { + return exec.LookPath(path) + } + + if filepath.VolumeName(path) != "" { + return exec.LookPath(path) + } + + if len(path) > 1 && os.IsPathSeparator(path[0]) { + return exec.LookPath(path) + } + + dirandpath := filepath.Join(dir, path) + + // We assume that LookPath will only add file extension. + lp, err := exec.LookPath(dirandpath) + if err != nil { + return "", err + } + + ext := strings.TrimPrefix(lp, dirandpath) + + return path + ext, nil +} + +func execEnvDefault(sys *syscall.SysProcAttr) (env []string, err error) { + if sys == nil || sys.Token == 0 { + return syscall.Environ(), nil + } + + var block *uint16 + err = windows.CreateEnvironmentBlock(&block, windows.Token(sys.Token), false) + if err != nil { + return nil, err + } + + defer windows.DestroyEnvironmentBlock(block) + blockp := uintptr(unsafe.Pointer(block)) + + for { + // find NUL terminator + end := unsafe.Pointer(blockp) + for *(*uint16)(end) != 0 { + end = unsafe.Pointer(uintptr(end) + 2) + } + + n := (uintptr(end) - uintptr(unsafe.Pointer(blockp))) / 2 + if n == 0 { + // environment block ends with empty string + break + } + + entry := (*[(1 << 30) - 1]uint16)(unsafe.Pointer(blockp))[:n:n] + env = append(env, string(utf16.Decode(entry))) + blockp += 2 * (uintptr(len(entry)) + 1) + } + return +} + +func isSlash(c uint8) bool { + return c == '\\' || c == '/' +} + +func normalizeDir(dir string) (name string, err error) { + ndir, err := syscall.FullPath(dir) + if err != nil { + return "", err + } + if len(ndir) > 2 && isSlash(ndir[0]) && isSlash(ndir[1]) { + // dir cannot have \\server\share\path form + return "", syscall.EINVAL + } + return ndir, nil +} + +func volToUpper(ch int) int { + if 'a' <= ch && ch <= 'z' { + ch += 'A' - 'a' + } + return ch +} + +func joinExeDirAndFName(dir, p string) (name string, err error) { + if len(p) == 0 { + return "", syscall.EINVAL + } + if len(p) > 2 && isSlash(p[0]) && isSlash(p[1]) { + // \\server\share\path form + return p, nil + } + if len(p) > 1 && p[1] == ':' { + // has drive letter + if len(p) == 2 { + return "", syscall.EINVAL + } + if isSlash(p[2]) { + return p, nil + } else { + d, err := normalizeDir(dir) + if err != nil { + return "", err + } + if volToUpper(int(p[0])) == volToUpper(int(d[0])) { + return syscall.FullPath(d + "\\" + p[2:]) + } else { + return syscall.FullPath(p) + } + } + } else { + // no drive letter + d, err := normalizeDir(dir) + if err != nil { + return "", err + } + if isSlash(p[0]) { + return windows.FullPath(d[:2] + p) + } else { + return windows.FullPath(d + "\\" + p) + } + } +} + +// createEnvBlock converts an array of environment strings into +// the representation required by CreateProcess: a sequence of NUL +// terminated strings followed by a nil. +// Last bytes are two UCS-2 NULs, or four NUL bytes. +func createEnvBlock(envv []string) *uint16 { + if len(envv) == 0 { + return &utf16.Encode([]rune("\x00\x00"))[0] + } + length := 0 + for _, s := range envv { + length += len(s) + 1 + } + length++ + + b := make([]byte, length) + i := 0 + for _, s := range envv { + l := len(s) + copy(b[i:i+l], []byte(s)) + copy(b[i+l:i+l+1], []byte{0}) + i = i + l + 1 + } + copy(b[i:i+1], []byte{0}) + + return &utf16.Encode([]rune(string(b)))[0] +} + +// dedupEnvCase is dedupEnv with a case option for testing. +// If caseInsensitive is true, the case of keys is ignored. +func dedupEnvCase(caseInsensitive bool, env []string) []string { + out := make([]string, 0, len(env)) + saw := make(map[string]int, len(env)) // key => index into out + for _, kv := range env { + eq := strings.Index(kv, "=") + if eq < 0 { + out = append(out, kv) + continue + } + k := kv[:eq] + if caseInsensitive { + k = strings.ToLower(k) + } + if dupIdx, isDup := saw[k]; isDup { + out[dupIdx] = kv + continue + } + saw[k] = len(out) + out = append(out, kv) + } + return out +} + +// addCriticalEnv adds any critical environment variables that are required +// (or at least almost always required) on the operating system. +// Currently this is only used for Windows. +func addCriticalEnv(env []string) []string { + for _, kv := range env { + eq := strings.Index(kv, "=") + if eq < 0 { + continue + } + k := kv[:eq] + if strings.EqualFold(k, "SYSTEMROOT") { + // We already have it. + return env + } + } + return append(env, "SYSTEMROOT="+os.Getenv("SYSTEMROOT")) +} diff --git a/term/go.mod b/term/go.mod new file mode 100644 index 00000000..6be4c4ff --- /dev/null +++ b/term/go.mod @@ -0,0 +1,5 @@ +module github.com/charmbracelet/x/term + +go 1.21 + +require golang.org/x/sys v0.16.0 diff --git a/term/go.sum b/term/go.sum new file mode 100644 index 00000000..ed3454fd --- /dev/null +++ b/term/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= From 16b694220035b62f380d28a4061273cc283a83b0 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 10 Jan 2024 11:57:54 -0500 Subject: [PATCH 2/4] fix: add windows build flags --- term/conpty/conpty_windows.go | 3 +++ term/conpty/exec_windows.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/term/conpty/conpty_windows.go b/term/conpty/conpty_windows.go index 86a05c52..5c7c9751 100644 --- a/term/conpty/conpty_windows.go +++ b/term/conpty/conpty_windows.go @@ -1,3 +1,6 @@ +//go:build windows +// +build windows + package conpty import ( diff --git a/term/conpty/exec_windows.go b/term/conpty/exec_windows.go index 43d89840..bbbbaae9 100644 --- a/term/conpty/exec_windows.go +++ b/term/conpty/exec_windows.go @@ -1,3 +1,6 @@ +//go:build windows +// +build windows + package conpty import ( From faf9e439b3aaedc98623799b03ba640a62137290 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 10 Jan 2024 11:56:27 -0500 Subject: [PATCH 3/4] feat: export I/O file descriptors --- term/conpty/conpty_windows.go | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/term/conpty/conpty_windows.go b/term/conpty/conpty_windows.go index 5c7c9751..2ad32215 100644 --- a/term/conpty/conpty_windows.go +++ b/term/conpty/conpty_windows.go @@ -96,9 +96,9 @@ func New(w int, h int, flags int) (c *ConPty, err error) { return } -// Handle returns the ConPty handle. -func (p *ConPty) Handle() windows.Handle { - return *p.hpc +// Fd returns the ConPty handle. +func (p *ConPty) Fd() uintptr { + return uintptr(*p.hpc) } // Close closes the ConPty device. @@ -116,11 +116,21 @@ func (p *ConPty) InPipe() *os.File { return p.inPipe } +// InPipeFd returns the ConPty input pipe file descriptor handle. +func (p *ConPty) InPipeFd() uintptr { + return uintptr(p.inPipeFd) +} + // OutPipe returns the ConPty output pipe. func (p *ConPty) OutPipe() *os.File { return p.outPipe } +// OutPipeFd returns the ConPty output pipe file descriptor handle. +func (p *ConPty) OutPipeFd() uintptr { + return uintptr(p.outPipeFd) +} + // Write safely writes bytes to the ConPty. func (c *ConPty) Write(p []byte) (n int, err error) { var l uint32 @@ -154,8 +164,8 @@ func (c *ConPty) Size() (w int, h int) { var zeroAttr syscall.ProcAttr -// Spawn creates a new process attached to the pseudo-console. -func (c *ConPty) Spawn(name string, args []string, attr *syscall.ProcAttr) (pid int, handle windows.Handle, err error) { +// Spawn spawns a new process attached to the pseudo-console. +func (c *ConPty) Spawn(name string, args []string, attr *syscall.ProcAttr) (pid int, handle uintptr, err error) { if attr == nil { attr = &zeroAttr } @@ -270,5 +280,5 @@ func (c *ConPty) Spawn(name string, args []string, attr *syscall.ProcAttr) (pid defer windows.CloseHandle(pi.Thread) - return int(pi.ProcessId), pi.Process, nil + return int(pi.ProcessId), uintptr(pi.Process), nil } From 8b23e8a7ca6459d975fcd69b16f2089faadead9d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 10 Jan 2024 12:17:36 -0500 Subject: [PATCH 4/4] fix: lower go version --- term/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/term/go.mod b/term/go.mod index 6be4c4ff..77e31a73 100644 --- a/term/go.mod +++ b/term/go.mod @@ -1,5 +1,5 @@ module github.com/charmbracelet/x/term -go 1.21 +go 1.17 require golang.org/x/sys v0.16.0