The use of pseudoterminals (PTYs) within the operating system kernel is the present state of the TTY (Teletypewriter) subsystem. These pseudoterminals are made up of a master PTY and a slave PTY that talk to each other via the TTY driver.
Using these ideas as a foundation, connecting to the PTY master to send and receive data is necessary to get a practical knowledge. This may be done in Golang by utilizing packages like github.com/creack/pty.
The first step in creating a straightforward endpoint emulator in Golang is to create a user interface (UI) and connect to the PTY driver using the pseudoterminal. By reading keyboard input and sending it to the PTY master, which interprets the input as if it were a genuine terminal, this link enables interaction with the terminal-like behavior. Let's Begin!
User Interface
First, we create the user interface. It's nothing special, just a triangle with readable text. I will be using Fyne UI Toolkit. Properly matured and recorded. Here is our first iteration:
package main import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/widget" ) func main() { a := app.New() w := a.NewWindow("germ") ui := widget.NewTextGrid() // Create a new TextGrid ui.SetText("I'm on a terminal!") // Set text to display // Create a new container with a wrapped layout // set the layout width to 420, height to 200 w.SetContent( fyne.NewContainerWithLayout( layout.NewGridWrapLayout(fyne.NewSize(420, 200)), ui, ), ) w.ShowAndRun()
Pseudoterminal
The next step is to install the TTY driver, which is in the kernel. For this task, we use Pseudoterminal. Like the TTY driver, the Pseudoterminal resides in the operating system kernel. It consists of a pair of pseudo devices, a master pty, and a slave pty.
package main
import (
"os"
"os/exec"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"github.com/creack/pty"
)
func main() {
a := app.New()
w := a.NewWindow("Terminal X")
ui := widget.NewTextGrid() // Create a new TextGrid
ui.SetText("Terminal X Successfully Running!!") // Set text to display
c := exec.Command("/bin/bash")
p, err := pty.Start(c)
if err != nil {
fyne.LogError("Failed to open pty", err)
os.Exit(1)
}
defer c.Process.Kill()
p.Write([]byte("ls\r"))
time.Sleep(1 * time.Second)
b := make([]byte, 1024)
_, err = p.Read(b)
if err != nil {
fyne.LogError("Failed to read pty", err)
}
// s := fmt.Sprintf("read bytes from pty.\nContent:%s", string(b))
ui.SetText(string(b))
// Create a new container with a wrapped layout
// set the layout width to 420, height to 200
w.SetContent(
fyne.NewContainerWithLayout(
layout.NewGridWrapLayout(fyne.NewSize(420, 200)),
ui,
),
)
w.ShowAndRun()
Keyboard Input
Now, we read the keyboard input and write it to the PTY master. Fyne's UI toolkit provides an easy way to capture keystrokes.
package main
import (
"os"
"os/exec"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"github.com/creack/pty"
)
func main() {
a := app.New()
w := a.NewWindow("Terminal X")
ui := widget.NewTextGrid() // Create a new TextGrid
ui.SetText("I'm on a terminal!") // Set text to display
c := exec.Command("/bin/bash")
p, err := pty.Start(c)
if err != nil {
fyne.LogError("Failed to open pty", err)
os.Exit(1)
}
defer c.Process.Kill()
onTypedKey := func(e *fyne.KeyEvent) {
if e.Name == fyne.KeyEnter || e.Name == fyne.KeyReturn {
_, _ = p.Write([]byte{'\r'})
}
}
onTypedRune := func(r rune) {
_, _ = p.WriteString(string(r))
}
w.Canvas().SetOnTypedKey(onTypedKey)
w.Canvas().SetOnTypedRune(onTypedRune)
go func() {
for {
time.Sleep(1 * time.Second)
b := make([]byte, 256)
_, err = p.Read(b)
if err != nil {
fyne.LogError("Failed to read pty", err)
}
ui.SetText(string(b))
}
}()
// Create a new container with a wrapped layout
// set the layout width to 420, height to 200
w.SetContent(
fyne.NewContainerWithLayout(
layout.NewGridWrapLayout(fyne.NewSize(420, 200)),
ui,
),
)
w.ShowAndRun()
}
When you run the above program and enter the command, say "ls ". You should see the UI update with the expected results. If you want to throw caution to the wind, run ping 8.8.8.8.
Bash runs as a thread created by our program. Bash receives the ping 8.8.8.8 command and creates a thread. We're not processing the signal yet, so Ctrl-C won't stop the task. You need to install the latest emulator.
Print on Screen
So far, we can enter commands in our terminal emulator, receive the command output, and dynamically output it to our UI. Let's make some improvements to the way we print to the screen. We will update our display mechanism to show a history of PTY results instead of just printing the last line on the screen. Pseudoterminal does not control the output history. We have to do this ourselves through the output buffer.
package main
import (
"bufio"
"io"
"os"
"os/exec"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"github.com/creack/pty"
)
// MaxBufferSize sets the size limit
// for our command output buffer.
const MaxBufferSize = 16
func main() {
a := app.New()
w := a.NewWindow("Terminal X")
ui := widget.NewTextGrid() // Create a new TextGrid
os.Setenv("TERM", "dumb")
c := exec.Command("/bin/bash")
p, err := pty.Start(c)
if err != nil {
fyne.LogError("Failed to open pty", err)
os.Exit(1)
}
defer c.Process.Kill()
// Callback function that handles special keypresses
onTypedKey := func(e *fyne.KeyEvent) {
if e.Name == fyne.KeyEnter || e.Name == fyne.KeyReturn {
_, _ = p.Write([]byte{'\r'})
}
}
// Callback function that handles character keypresses
onTypedRune := func(r rune) {
_, _ = p.WriteString(string(r))
}
w.Canvas().SetOnTypedKey(onTypedKey)
w.Canvas().SetOnTypedRune(onTypedRune)
buffer := [][]rune{}
reader := bufio.NewReader(p)
// Goroutine that reads from pty
go func() {
line := []rune{}
buffer = append(buffer, line)
for {
r, _, err := reader.ReadRune()
if err != nil {
if err == io.EOF {
return
}
os.Exit(0)
}
line = append(line, r)
buffer[len(buffer)-1] = line
if r == '\n' {
if len(buffer) > MaxBufferSize { // If the buffer is at capacity...
buffer = buffer[1:] // ...pop the first line in the buffer
}
line = []rune{}
buffer = append(buffer, line)
}
}
}()
// Goroutine that renders to UI
go func() {
for {
time.Sleep(100 * time.Millisecond)
ui.SetText("")
var lines string
for _, line := range buffer {
lines = lines + string(line)
}
ui.SetText(string(lines))
}
}()
// Create a new container with a wrapped layout
// set the layout width to 900, height to 325
w.SetContent(
fyne.NewContainerWithLayout(
layout.NewGridWrapLayout(fyne.NewSize(900, 325)),
ui,
),
)
w.ShowAndRun()
}
Conclusion
It's essential to realize that our adventure is far from finished now that we've created a simple terminal emulator with only 100 lines of Go code. We still have a long way to go before we can fully develop our application and make it into an emulator that can serve a variety of purposes. We will conduct a thorough investigation of ANSI escape codes, special characters, and a myriad of other essential elements that are essential to a reliable terminal emulator in the upcoming parts of this series. As we continue to increase our knowledge and experience in this interesting field, I want you to openly share any bugs you might have or to mention any specific areas that catch your attention in the code.
Enjoyed this post? Never miss out on future posts by «following us»