Files
sessman3270/screens.go
T
2026-10-17 21:12:15 +00:00

482 lines
12 KiB
Go

package main
import (
"fmt"
"log"
"net"
"strconv"
"strings"
"time"
"github.com/racingmars/go3270"
)
const screenSeparator = "==============================================================================="
func centerCol(text string) int {
return max(0, (80-len(text))/2-1)
}
// send an ephemeral centered status/error notification overlay to the client
func send3270Message(conn net.Conn, msg string, cp go3270.Codepage) {
if conn == nil {
return
}
col := centerCol(msg)
screen := go3270.Screen{
{Row: 11, Col: 0, Content: screenSeparator},
{Row: 12, Col: col, Content: msg, Intense: true},
{Row: 13, Col: 0, Content: screenSeparator},
}
values := make(map[string]string)
_, _ = go3270.ShowScreenOpts(screen, values, conn, go3270.ScreenOpts{
Codepage: cp,
NoResponse: true,
CursorRow: 12,
CursorCol: col + 1,
})
}
func promptLogin(conn net.Conn, devInfo go3270.DevInfo) (LoginCredentials, error) {
title := GlobalConfig.Login.Title
subtitle := GlobalConfig.Login.Subtitle
instructions := GlobalConfig.Login.Instructions
promptUser := GlobalConfig.Login.PromptUsername
promptPass := GlobalConfig.Login.PromptPassword
var cp go3270.Codepage
if devInfo != nil {
cp = devInfo.Codepage()
}
errMsg := ""
for {
screen := go3270.Screen{
{Row: 0, Col: 0, Content: screenSeparator},
{Row: 1, Col: centerCol(title), Content: title, Intense: true},
{Row: 2, Col: centerCol(subtitle), Content: subtitle},
{Row: 3, Col: 0, Content: screenSeparator},
// Username row
{Row: 7, Col: 10, Content: promptUser},
{Row: 7, Col: 25, Name: "username", Write: true},
{Row: 7, Col: 46, Content: ""},
// Password row
{Row: 9, Col: 10, Content: promptPass},
{Row: 9, Col: 25, Name: "password", Write: true, Hidden: true},
{Row: 9, Col: 46, Content: ""},
// Footer
{Row: 22, Col: 2, Content: instructions},
}
if errMsg != "" {
screen = append(screen, go3270.Field{
Row: 12,
Col: centerCol(errMsg),
Content: errMsg,
Intense: true,
})
}
values := make(map[string]string)
resp, err := go3270.ShowScreenOpts(screen, values, conn, go3270.ScreenOpts{
Codepage: cp,
CursorRow: 7,
CursorCol: 26,
})
if err != nil {
return LoginCredentials{}, err
}
if resp.AID == go3270.AIDClear {
errMsg = ""
continue
}
user := strings.TrimSpace(resp.Values["username"])
pass := strings.TrimSpace(resp.Values["password"])
if pass == "" {
errMsg = "Password is required."
continue
}
if user == "" {
user = "IBMUSER"
}
key := computeSessionKey(user, pass)
return LoginCredentials{
Username: user,
Password: pass,
SessionKey: key,
}, nil
}
}
func buildMainMenuScreen(s *UserMasterSession) go3270.Screen {
menuTitle := ""
menuFooter := ""
promptSelect := ""
if GlobalConfig != nil {
menuTitle = GlobalConfig.Menu.Title
menuFooter = GlobalConfig.Menu.Footer
promptSelect = GlobalConfig.Menu.PromptSelect
}
total := len(ConfiguredSystem)
s.Mu.Lock()
username := s.Username
displayTag := s.DisplayTag
activeSlots := s.countActiveSlotsLocked()
page, totalPages, startIdx, endIdx := menuPageBounds(s.MenuPage, total)
renderedSlots := make([]slotRenderInfo, endIdx-startIdx)
for idx := startIdx; idx < endIdx; idx++ {
i := idx - startIdx
status := "INACTIVE"
var color go3270.Color
hasNotice := false
if idx < len(s.Slots) && s.Slots[idx] != nil {
slot := s.Slots[idx]
slot.Mu.Lock()
if slot.IsHeld {
status = "HELD"
color = go3270.Red
} else if slot.ScreenChanged {
status = "NOTICE"
hasNotice = true
} else {
status = "ACTIVE"
color = go3270.Yellow
}
slot.Mu.Unlock()
}
renderedSlots[i] = slotRenderInfo{status: status, color: color, hasNotice: hasNotice}
}
s.Mu.Unlock()
screen := go3270.Screen{
{Row: 0, Col: 0, Content: screenSeparator},
{Row: 1, Col: centerCol(menuTitle), Content: menuTitle, Intense: true},
{Row: 2, Col: 2, Content: fmt.Sprintf("User: %-10s Session Tag: #%-8s", username, displayTag)},
{Row: 2, Col: 58, Content: fmt.Sprintf("Active Sessions: %d", activeSlots)},
{Row: 3, Col: 0, Content: screenSeparator},
{Row: 5, Col: 2, Content: "CMD ID System Name Description Status", Intense: true},
{Row: 6, Col: 2, Content: "--- ---- --------------- ------------------------------- -----------"},
}
row := 7
for idx := startIdx; idx < endIdx; idx++ {
sys := ConfiguredSystem[idx]
info := renderedSlots[idx-startIdx]
line := fmt.Sprintf("[%2d] %-15.15s %-31.31s %s", idx+1, sys.Name, sys.Description, info.status)
screen = append(screen,
go3270.Field{
Row: row,
Col: 2,
Name: fmt.Sprintf("cmd_%d", idx+1),
Write: true,
},
go3270.Field{
Row: row,
Col: 6,
Content: line,
Intense: info.hasNotice,
Color: info.color,
},
)
row++
}
screen = append(screen,
go3270.Field{Row: 21, Col: 2, Content: promptSelect},
go3270.Field{Row: 21, Col: 18, Name: "opt", Write: true},
go3270.Field{Row: 21, Col: 78, Content: ""},
go3270.Field{Row: 23, Col: 2, Content: menuFooter},
)
if totalPages > 1 {
pageIndicator := fmt.Sprintf("Page %d of %d", page+1, totalPages)
pageCol := max(0, 79-len(pageIndicator))
screen = append(screen, go3270.Field{Row: 22, Col: pageCol, Content: pageIndicator})
}
return screen
}
func renderMainMenu(s *UserMasterSession, clientConn net.Conn) (go3270.Response, error) {
screen := buildMainMenuScreen(s)
values := make(map[string]string)
cp := s.Codepage()
return go3270.ShowScreenOpts(screen, values, clientConn, go3270.ScreenOpts{
Codepage: cp,
CursorRow: 21,
CursorCol: 19,
})
}
func runMenuLoop(s *UserMasterSession, clientConn net.Conn) {
s.Mu.Lock()
if s.ClientConn == clientConn {
s.IsDisconnected = false
}
s.Mu.Unlock()
for {
s.Mu.Lock()
if s.ClientConn != clientConn {
s.Mu.Unlock()
return
}
s.Mu.Unlock()
resp, err := renderMainMenu(s, clientConn)
if err != nil {
s.Mu.Lock()
username, displayTag := s.Username, s.DisplayTag
if s.ClientConn == clientConn {
s.IsDisconnected = true
s.Mu.Unlock()
log.Printf("[%s] '%s' Client disconnected while on system menu: %v", displayTag, username, err)
cleanupSessionIfEmpty(s)
} else {
s.Mu.Unlock()
log.Printf("[%s] '%s' Previous connection transferred to new terminal", displayTag, username)
}
return
}
switch resp.AID {
case go3270.AIDPF3:
disconnectClient(s, clientConn,
"Disconnecting terminal... Session active in background.",
"User pressed PF3 to disconnect. Preserving session.")
return
case go3270.AIDPF7, go3270.AIDPF19:
s.Mu.Lock()
page, _, _, _ := menuPageBounds(s.MenuPage, len(ConfiguredSystem))
if page > 0 {
s.MenuPage = page - 1
} else {
s.MenuPage = 0
}
s.Mu.Unlock()
continue
case go3270.AIDPF8, go3270.AIDPF20:
s.Mu.Lock()
page, totalPages, _, _ := menuPageBounds(s.MenuPage, len(ConfiguredSystem))
if page < totalPages-1 {
s.MenuPage = page + 1
} else {
s.MenuPage = page
}
s.Mu.Unlock()
continue
case go3270.AIDEnter:
s.Mu.Lock()
page, _, startIdx, endIdx := menuPageBounds(s.MenuPage, len(ConfiguredSystem))
s.MenuPage = page
username, displayTag := s.Username, s.DisplayTag
isAdmin := s.IsAdmin
s.Mu.Unlock()
connectSlot := -1
for idx := startIdx; idx < endIdx; idx++ {
fieldName := fmt.Sprintf("cmd_%d", idx+1)
cmdVal := strings.TrimSpace(resp.Values[fieldName])
if cmdVal == "" {
continue
}
switch strings.ToLower(cmdVal) {
case "k":
killSlot(s, idx)
case "h", "r":
hold := strings.EqualFold(cmdVal, "h")
s.Mu.Lock()
if idx < len(s.Slots) && s.Slots[idx] != nil {
slot := s.Slots[idx]
slot.Mu.Lock()
slot.IsHeld = hold
if hold {
slot.ScreenChanged = false
}
slot.Mu.Unlock()
if hold {
log.Printf("[%s] '%s' Placed slot [%d] %s on HOLD", displayTag, username, idx+1, slot.System.Name)
} else {
log.Printf("[%s] '%s' RELEASED slot [%d] %s from hold", displayTag, username, idx+1, slot.System.Name)
}
}
s.Mu.Unlock()
case "b":
log.Printf("[%s] '%s' CMD 'B' entered -> starting [%d] %s in background", displayTag, username, idx+1, ConfiguredSystem[idx].Name)
ensureSlotConnected(s, idx, true, clientConn)
default:
if connectSlot == -1 {
connectSlot = idx
}
}
}
if connectSlot >= 0 {
log.Printf("[%s] '%s' CMD entered -> connecting to [%d] %s", displayTag, username, connectSlot+1, ConfiguredSystem[connectSlot].Name)
} else {
optStr := strings.TrimSpace(resp.Values["opt"])
if optStr == "" {
continue
}
parts := strings.Fields(optStr)
cmd := strings.ToLower(parts[0])
if isAdmin {
switch cmd {
case "bye":
handleAdminBye(s)
return
case "killall":
handleAdminKillAll(s)
return
case "kill":
if len(parts) < 2 {
send3270Message(clientConn, "Usage: kill <sessionid>", s.Codepage())
time.Sleep(adminNoticeSleep)
continue
}
if handleAdminKill(s, parts[1]) {
return
}
continue
}
}
selNum, err := strconv.Atoi(optStr)
if err != nil || selNum < 1 || selNum > len(ConfiguredSystem) {
log.Printf("[%s] '%s' Invalid option '%s' entered by user", displayTag, username, optStr)
send3270Message(clientConn, fmt.Sprintf("Invalid option '%s'. Please enter a number between 1 and %d.", optStr, len(ConfiguredSystem)), s.Codepage())
time.Sleep(adminNoticeSleep)
continue
}
connectSlot = selNum - 1
log.Printf("[%s] '%s' User selected option %d -> connecting to [%d] %s", displayTag, username, selNum, selNum, ConfiguredSystem[connectSlot].Name)
}
connectOrResumeSlot(s, connectSlot, clientConn)
s.Mu.Lock()
if s.ClientConn != clientConn || s.IsDisconnected {
s.Mu.Unlock()
return
}
s.Mu.Unlock()
}
}
}
func disconnectClient(s *UserMasterSession, clientConn net.Conn, msg, logMsg string) {
s.Mu.Lock()
if s.ClientConn == clientConn {
s.IsDisconnected = true
s.ActiveSlotIdx = -1
username, displayTag := s.Username, s.DisplayTag
cp := s.codepageLocked()
s.Mu.Unlock()
log.Printf("[%s] '%s' %s", displayTag, username, logMsg)
send3270Message(clientConn, msg, cp)
_ = clientConn.Close()
cleanupSessionIfEmpty(s)
} else {
s.Mu.Unlock()
}
}
func handleResumeSession(s *UserMasterSession, clientConn net.Conn) {
s.Mu.Lock()
if s.ClientConn == clientConn {
s.IsDisconnected = false
}
username, displayTag := s.Username, s.DisplayTag
activeSlots := s.countActiveSlotsLocked()
cp := s.codepageLocked()
s.Mu.Unlock()
log.Printf("[%s] '%s' has active background sessions. Presenting resume", displayTag, username)
menuTitle := ""
if GlobalConfig != nil {
menuTitle = GlobalConfig.Menu.Title
}
screen := go3270.Screen{
{Row: 0, Col: 0, Content: screenSeparator},
{Row: 1, Col: centerCol(menuTitle), Content: menuTitle, Intense: true},
{Row: 2, Col: 2, Content: fmt.Sprintf("User: %-10s Session Tag: #%-8s", username, displayTag)},
{Row: 2, Col: 58, Content: fmt.Sprintf("Active Sessions: %2d", activeSlots)},
{Row: 3, Col: 0, Content: screenSeparator},
{Row: 10, Col: 14, Content: fmt.Sprintf("Welcome back, %s!", username)},
{Row: 22, Col: 2, Content: "[Enter] System Menu [PF3] Disconnect"},
}
values := make(map[string]string)
resp, err := go3270.ShowScreenOpts(screen, values, clientConn, go3270.ScreenOpts{
Codepage: cp,
CursorRow: 22,
CursorCol: 2,
})
if err != nil {
s.Mu.Lock()
if s.ClientConn == clientConn {
s.IsDisconnected = true
s.ActiveSlotIdx = -1
s.Mu.Unlock()
log.Printf("[%s] '%s' disconnected during resume prompt (%v)", displayTag, username, err)
cleanupSessionIfEmpty(s)
} else {
s.Mu.Unlock()
log.Printf("[%s] '%s' Connection transferred during resume prompt", displayTag, username)
}
return
}
if resp.AID == go3270.AIDPF3 {
disconnectClient(s, clientConn, "Disconnecting... ", "PF3 disconnect during resume prompt")
return
}
runMenuLoop(s, clientConn)
}
func menuPageBounds(menuPage, totalSystems int) (page, totalPages, startIdx, endIdx int) {
totalPages = (totalSystems + 12) / 13
if totalPages == 0 {
totalPages = 1
}
if menuPage < 0 {
page = 0
} else if menuPage >= totalPages {
page = totalPages - 1
} else {
page = menuPage
}
startIdx = page * 13
endIdx = startIdx + 13
if endIdx > totalSystems {
endIdx = totalSystems
}
return page, totalPages, startIdx, endIdx
}