// Package console provides the non-TUI console mode. package console import ( "fmt" "io" "os" "os/signal" "strconv" "strings" tea "github.com/charmbracelet/bubbletea" "golang.org/x/term" apppkg "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/app" "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/config" "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/flag" "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/session" "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/tui" ) // Run parses flags, sets up the session and app, then runs TUI or console mode. func Run() { defer func() { if r := recover(); r != nil { fmt.Fprintf(os.Stderr, "fatal: %v\n", r) os.Exit(1) } }() cfg := &config.Config{} flag.Init(cfg) flag.Normalize() flag.Parse() flag.Ext(cfg) if cfg.PortName == "" { flag.GetCliFlag(cfg) } ports, err := session.CheckPortAvailability(cfg.PortName) if err != nil { fmt.Println(err) flag.PrintUsage(ports) os.Exit(0) } sess, err := session.Open(cfg) if err != nil { fmt.Fprintf(os.Stderr, "open session failed: %v\n", err) os.Exit(1) } appInst, err := apppkg.New(cfg, sess, os.Stdout) if err != nil { fmt.Fprintf(os.Stderr, "create app failed: %v\n", err) os.Exit(1) } defer appInst.Close() appInst.LoadConfiguredForwards() appInst.StartOutputLoop() go forwardInterruptToRemote(appInst) appInst.SetUIEnabled(cfg.EnableGUI) if cfg.EnableGUI { model := tui.New(appInst) p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithoutSignalHandler()) if _, err = p.Run(); err != nil { fmt.Fprintf(os.Stderr, "tui failed: %v\n", err) os.Exit(1) } return } if err = RunConsole(appInst); err != nil { fmt.Fprintf(os.Stderr, "console failed: %v\n", err) os.Exit(1) } } func forwardInterruptToRemote(appInst *apppkg.App) { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt) defer signal.Stop(sigCh) for { select { case <-appInst.WaitDone(): return case <-sigCh: if err := appInst.SendCtrl('c'); err != nil { appInst.Notifyf("[signal] interrupt pass-through failed: %v", err) continue } appInst.Notifyf("[signal] Ctrl+C forwarded to remote") } } } // RunConsole runs the non-TUI console mode. func RunConsole(appInst *apppkg.App) error { fd := int(os.Stdin.Fd()) isTerm := term.IsTerminal(fd) var oldState *term.State var err error if isTerm { enableVTInput(fd) oldState, err = term.MakeRaw(fd) if err != nil { return err } defer func() { _ = term.Restore(fd, oldState) }() } appInst.Notifyf("[console] non-gui mode, commands start with '.' at line start\n") appInst.Notifyf("[console] Ctrl+ passes through to remote; .exit to exit") ch := make(chan byte, 1024) errCh := make(chan error, 1) go func() { buf := make([]byte, 256) for { n, rdErr := os.Stdin.Read(buf) if rdErr != nil { errCh <- rdErr return } for i := 0; i < n; i++ { ch <- buf[i] } } }() out := appInst.Out() cfg := appInst.Cfg() lineStart := true commandMode := false cmdBuf := make([]byte, 0, 128) tryRead := func() (byte, bool) { select { case b := <-ch: return b, true default: return 0, false } } readByte := func() (byte, error) { select { case <-appInst.WaitDone(): return 0, io.EOF case rdErr := <-errCh: return 0, rdErr case b := <-ch: return b, nil } } flushESC := func(seq []byte) bool { if isExitHotkeySeq(seq, cfg) { appInst.Close() return true } if err = appInst.WriteToSession(seq); err != nil { appInst.Statusf("[send] %v", err) } return false } for { b, rdErr := readByte() if rdErr != nil { if rdErr == io.EOF { return nil } return rdErr } if b == 0x1b { escBuf := []byte{0x1b} for { nb, ok := tryRead() if !ok { if err = appInst.WriteToSession([]byte{0x1b}); err != nil { appInst.Statusf("[send] %v", err) } break } escBuf = append(escBuf, nb) if nb >= 0x40 && nb <= 0x7e { if flushESC(escBuf) { return nil } break } if len(escBuf) == 2 && escBuf[1] != '[' { if flushESC(escBuf) { return nil } break } if len(escBuf) > 16 { if err = appInst.WriteToSession(escBuf); err != nil { appInst.Statusf("[send] %v", err) } break } } continue } if b == 0x00 { if b2, ok := tryRead(); ok { if isAltKeyExit(b2, cfg) { appInst.Close() return nil } if err = appInst.WriteToSession([]byte{0x00, b2}); err != nil { appInst.Statusf("[send] %v", err) } } else { if err = appInst.WriteToSession([]byte{0x00}); err != nil { appInst.Statusf("[send] %v", err) } } if commandMode { lineStart = false } continue } if commandMode { switch b { case '\r', '\n': echoConsoleNewline(out) line := string(cmdBuf) if strings.TrimSpace(line) != "" { appInst.HandleLine(line) } commandMode = false cmdBuf = cmdBuf[:0] lineStart = true case 0x7f, 0x08: if len(cmdBuf) > 0 { cmdBuf = cmdBuf[:len(cmdBuf)-1] echoConsoleBackspace(out) } case 0x09: line, cands := appInst.Dispatcher().Complete(string(cmdBuf)) if len(cands) == 1 { cmdBuf = append(cmdBuf[:0], line...) echoRedrawCommand(out, line) } else if len(cands) > 1 { echoConsoleNewline(out) appInst.Notifyf("%s", strings.Join(cands, " ")) echoConsoleByte(out, '.') echoConsoleString(out, string(cmdBuf[1:])) } default: cmdBuf = append(cmdBuf, b) echoConsoleByte(out, b) } continue } if lineStart && b == '.' { commandMode = true cmdBuf = append(cmdBuf[:0], b) echoConsoleByte(out, b) continue } if b == '\r' || b == '\n' { if err = appInst.WriteToSession([]byte(cfg.EndStr)); err != nil { appInst.Statusf("[send] %v", err) } lineStart = true } else { if err = appInst.WriteToSession([]byte{b}); err != nil { appInst.Statusf("[send] %v", err) } lineStart = false } } } func parseCSIu(seq []byte) (cp int, mod int, ok bool) { if len(seq) < 6 { return 0, 0, false } if seq[0] != 0x1b || seq[1] != '[' { return 0, 0, false } if seq[len(seq)-1] != 'u' { return 0, 0, false } inner := string(seq[2 : len(seq)-1]) parts := strings.SplitN(inner, ";", 2) if len(parts) != 2 { return 0, 0, false } cp, err := strconv.Atoi(parts[0]) if err != nil { return 0, 0, false } mod, err = strconv.Atoi(parts[1]) if err != nil { return 0, 0, false } return cp, mod, true } func isAltKeyExit(b byte, cfg *config.Config) bool { if normalizeHotkey(cfg.HotkeyMod) != "ctrl+alt" { return false } return b == 0x2e || b == 0x03 || b == 0x63 || b == 0x43 } func isExitHotkeySeq(seq []byte, cfg *config.Config) bool { mod := normalizeHotkey(cfg.HotkeyMod) if cp, cmod, ok := parseCSIu(seq); ok { if cp != 'c' && cp != 'C' { return false } switch mod { case "ctrl+alt": return cmod&6 == 6 case "ctrl+shift": return cmod&5 == 5 } return false } return false } func normalizeHotkey(mod string) string { mod = strings.ToLower(strings.TrimSpace(mod)) if mod != "ctrl+alt" && mod != "ctrl+shift" { mod = "ctrl+alt" } return mod } func echoConsoleByte(out io.Writer, b byte) { _, _ = out.Write([]byte{b}) } func echoConsoleNewline(out io.Writer) { _, _ = io.WriteString(out, "\r\n") } func echoConsoleBackspace(out io.Writer) { _, _ = io.WriteString(out, "\b \b") } func echoConsoleString(out io.Writer, s string) { _, _ = io.WriteString(out, s) } func echoRedrawCommand(out io.Writer, s string) { _, _ = io.WriteString(out, "\r\033[K> "+s) }