diff --git a/.gitignore b/.gitignore index a91d00a..2fe244d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ example1/example1 example2/example2 example3/example3 example4/example4 +example5/example5 diff --git a/README.md b/README.md index a83b744..ae07f50 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,13 @@ This library allows you to write Go servers for tn3270 clients by building 3270 Usage ----- -See the example folders for quick demonstrations of using the library. example1 uses the lower-level function ShowScreenOpts(), and example2 uses a higher-level function HandleScreen(). example3 demonstrates updating the client's 3270 display while waiting for a response by using an update thread and a waiting response thread. +See the example folders for quick demonstrations of using the library: + + * example1 uses the lower-level function ShowScreenOpts(). + * example2 uses a higher-level convenience function HandleScreen(). + * example3 demonstrates updating the client's 3270 display while waiting for a response by using an update thread and a waiting response thread. + * example4 demonstrates the RunTransactions() approach to handing control from one screen to the next. This is the recommended way to build applications using go3270. + * example5 demonstrates support for larger-than-default (24x80) terminal sizes. **NEW**: For larger applications, I recommend using the `RunTransactions()` function to serve as the driver for your application. You can implement transaction functions which pass control from one transaction to another. example4 demonstrates a "larger" application that uses this approach. @@ -18,22 +24,17 @@ Here's [a video introducing the library][introVideo] as well. [introVideo]: https://www.youtube.com/watch?v=h9XTjup5W5U -Known Problems --------------- - - - The telnet negotiation does not check for any errors or for any responses from the client. We just assume it goes well and we're actually talking to a tn3270 client. - - Screen size is limited to exactly 24x80. In the future, the terminal type could be interrogated and the library could support the screen sizes of other 3270 models. - 3270 information ---------------- -Everything I know about 3270 data streams I learned from [Tommy Sprinkle's tutorial][sprinkle]. The tn3270 telnet negotiation is gleaned from [RFC 1576: TN3270 Current Practices][rfc1576], [RFC 1041: Telnet 3270 Regime Option][rfc1041], and [RFC 854: Telnet Protocol Specification][rfc854]. The IANA maintains a [useful reference of telnet option numbers][telnetOptions]. +I started learning about 3270 data streams from [Tommy Sprinkle's tutorial][sprinkle]. The tn3270 telnet negotiation is gleaned from [RFC 1576: TN3270 Current Practices][rfc1576], [RFC 1041: Telnet 3270 Regime Option][rfc1041], and [RFC 854: Telnet Protocol Specification][rfc854]. The IANA maintains a [useful reference of telnet option numbers][telnetOptions]. The reference I use for 3270 data streams is [the 1981 version from IBM][ibmref]. [sprinkle]: http://www.tommysprinkle.com/mvs/P3270/ [rfc1576]: https://tools.ietf.org/html/rfc1576 [rfc1041]: https://tools.ietf.org/html/rfc1041 [rfc854]: https://tools.ietf.org/html/rfc854 [telnetOptions]: https://www.iana.org/assignments/telnet-options/telnet-options.xhtml +[ibmref]: https://bitsavers.org/pdf/ibm/3270/GA23-0059-0_3270_Data_Stream_Programmers_Reference_Jan1981.pdf License ------- diff --git a/bufaddr_test.go b/bufaddr_test.go index f296087..f34a576 100644 --- a/bufaddr_test.go +++ b/bufaddr_test.go @@ -9,15 +9,21 @@ import ( ) func TestEncode(t *testing.T) { - encoded := getpos(0, 0) + encoded := getpos(0, 0, 80) if encoded[0] != 0x40 || encoded[1] != 0x40 { t.Error("Position (0, 0) not correctly encoded") } - encoded = getpos(11, 39) + encoded = getpos(11, 39, 80) if encoded[0] != 0x4e || encoded[1] != 0xd7 { t.Error("Position (11, 39) not correctly encoded") } + + // Large screen, 14-bit addressing + encoded = getpos(100, 120, 130) + if encoded[0] != 0x33 || encoded[1] != 0x40 { + t.Errorf("Position (100, 120) on 130-col screen not correctly encoded") + } } func TestDecode(t *testing.T) { @@ -30,4 +36,10 @@ func TestDecode(t *testing.T) { if decoded != 919 { t.Error("Buffer address incorrectly decoded") } + + // Large screen, 14-bit addressing + decoded = decodeBufAddr([2]byte{0x33, 0x40}) + if decoded != 13120 { + t.Error("14-bit buffer address incorrectly decoded") + } } diff --git a/example1/example1.go b/example1/example1.go index 69dd363..904d202 100644 --- a/example1/example1.go +++ b/example1/example1.go @@ -91,7 +91,10 @@ func handle(conn net.Conn) { defer conn.Close() // Always begin new connection by negotiating the telnet options - go3270.NegotiateTelnet(conn) + if _, err := go3270.NegotiateTelnet(conn); err != nil { + fmt.Printf("ERROR: %v\n", err) + return + } fieldValues := make(map[string]string) var response go3270.Response diff --git a/example2/example2.go b/example2/example2.go index bb270bf..ee4c609 100644 --- a/example2/example2.go +++ b/example2/example2.go @@ -90,7 +90,10 @@ func handle(conn net.Conn) { defer conn.Close() // Always begin new connection by negotiating the telnet options - go3270.NegotiateTelnet(conn) + if _, err := go3270.NegotiateTelnet(conn); err != nil { + fmt.Println(err) + return + } fieldValues := make(map[string]string) diff --git a/example3/example3.go b/example3/example3.go index 046caa3..ef9ab43 100644 --- a/example3/example3.go +++ b/example3/example3.go @@ -49,7 +49,10 @@ func handle(conn net.Conn) { defer conn.Close() // Always begin new connection by negotiating the telnet options - go3270.NegotiateTelnet(conn) + if _, err := go3270.NegotiateTelnet(conn); err != nil { + fmt.Println(err) + return + } // First, let's send the initial screen and wait forever for the user to // press PF3, and when we get it, send a message on the done channel. diff --git a/example4/example4.go b/example4/example4.go index 2a3da28..eaac779 100644 --- a/example4/example4.go +++ b/example4/example4.go @@ -75,8 +75,13 @@ func handle(conn net.Conn, db DB, gblstate *global) { }() // Always begin new connection by negotiating the telnet options - go3270.NegotiateTelnet(conn) - err := go3270.RunTransactions(conn, state.login, nil) + devinfo, err := go3270.NegotiateTelnet(conn) + if err != nil { + fmt.Println(err) + return + } + + err = go3270.RunTransactions(conn, devinfo, state.login, nil) if err != nil { fmt.Println(err) } diff --git a/example4/help.go b/example4/help.go index abbaafb..74b6db8 100644 --- a/example4/help.go +++ b/example4/help.go @@ -48,7 +48,8 @@ var helpScreen = go3270.Screen{ // generator function for it is a stand-alone function and not a method on the // session. func help(returnTransaction go3270.Tx) go3270.Tx { - return func(conn net.Conn, data any) (go3270.Tx, any, error) { + return func(conn net.Conn, _ go3270.DevInfo, data any) ( + go3270.Tx, any, error) { _, err := go3270.HandleScreen( helpScreen, // the screen to display nil, // (no) rules to enforce diff --git a/example4/login.go b/example4/login.go index 4b91f5a..ba9a73b 100644 --- a/example4/login.go +++ b/example4/login.go @@ -58,7 +58,8 @@ var loginScreenRules = go3270.Rules{ // login transaction accepts a string value in data if the login screen // should be initialized with an error message. -func (sess *session) login(conn net.Conn, data any) (go3270.Tx, any, error) { +func (sess *session) login(conn net.Conn, _ go3270.DevInfo, + data any) (go3270.Tx, any, error) { fieldValues := make(map[string]string) @@ -169,7 +170,8 @@ type newuserData struct { errmsg string } -func (sess *session) newuser(conn net.Conn, data any) (go3270.Tx, any, error) { +func (sess *session) newuser(conn net.Conn, _ go3270.DevInfo, data any) ( + go3270.Tx, any, error) { fieldValues := make(map[string]string) diff --git a/example4/mainmenu.go b/example4/mainmenu.go index 3f8390a..fdaca98 100644 --- a/example4/mainmenu.go +++ b/example4/mainmenu.go @@ -79,7 +79,8 @@ type mainmenuData struct { // mainmenu transaction accepts a mainmenuData struct as the data if the // option field or error message should be populated. -func (sess *session) mainmenu(conn net.Conn, data any) (go3270.Tx, any, error) { +func (sess *session) mainmenu(conn net.Conn, _ go3270.DevInfo, data any) ( + go3270.Tx, any, error) { fieldValues := make(map[string]string) @@ -182,8 +183,8 @@ var exampleScreen = go3270.Screen{ // exampleFeature is a transaction that will act as a placeholder for real // application functionality. It accepts a string in the data which will // be displayed on the panel. -func (sess *session) exampleFeature(conn net.Conn, data any) (go3270.Tx, - any, error) { +func (sess *session) exampleFeature(conn net.Conn, _ go3270.DevInfo, + data any) (go3270.Tx, any, error) { fieldValues := make(map[string]string) diff --git a/example5/bigscreen.go b/example5/bigscreen.go new file mode 100644 index 0000000..1a58c54 --- /dev/null +++ b/example5/bigscreen.go @@ -0,0 +1,124 @@ +// This file is part of https://github.com/racingmars/go3270/ +// Copyright 2025 by Matthew R. Wilson, licensed under the MIT license. See +// LICENSE in the project root for license information. + +package main + +import ( + "fmt" + "net" + "strconv" + + "github.com/racingmars/go3270" +) + +var biglayout = go3270.Screen{ + // Column will be calculated at runtime for the following field: + {Row: 0, Intense: true, Content: "3270 Screen Size Example"}, + + {Row: 2, Col: 0, + Content: "This screen is using the full size that your terminal supports."}, + + {Row: 4, Col: 0, Content: "Terminal Type . . ."}, + {Row: 4, Col: 21, Name: "termtype", Intense: true}, + + {Row: 5, Col: 0, Content: "Rows . . . . . . . ."}, + {Row: 5, Col: 21, Name: "rows", Intense: true}, + + {Row: 6, Col: 0, Content: "Columns . . . . . ."}, + {Row: 6, Col: 21, Name: "cols", Intense: true}, + + {Row: 8, Col: 0, Content: "To visit a default sized screen, press"}, + {Row: 8, Col: 39, Content: "PF1", Color: go3270.Yellow, Intense: true}, + + {Row: 9, Col: 0, Content: "To exit and disconnect, press"}, + {Row: 9, Col: 30, Content: "PF3", Color: go3270.Yellow, Intense: true}, + + // a blank field for error messages + {Row: 11, Col: 0, Intense: true, Color: go3270.Red, Name: "errormsg"}, +} + +func bigscreen(conn net.Conn, devinfo go3270.DevInfo, data any) ( + go3270.Tx, any, error) { + + rows, cols := devinfo.AltDimensions() + termtype := devinfo.TerminalType() + + // Make a local copy of the screen definition that we can append lines to. + screen := make(go3270.Screen, len(biglayout)) + copy(screen, biglayout) + + // Center the title on any screen width + screen[0].Col = (cols / 2) - (len(biglayout[0].Content) / 2) + + // We'll start writing "data lines" at row 13 up to the penultimate row on + // the terminal + for i := 13; i < rows-1; i++ { + newfield := go3270.Field{Row: i, Col: 0, + Content: fmt.Sprintf("This is data row %d.", i-12)} + if i == rows-2 { + newfield.Content += " (The last.)" + } + screen = append(screen, newfield) + + // And demonstrate that we can position to the full width, too. + newfield = go3270.Field{Row: i, Col: cols - 5, Content: "<**>"} + screen = append(screen, newfield) + } + + // And an input field on the last row, to make sure field buffer address + // decoding works on larger screens. + newfield := go3270.Field{Row: rows - 1, Col: 0, + Content: "Enter data here:", Color: go3270.Pink, Intense: true} + screen = append(screen, newfield) + newfield = go3270.Field{Row: rows - 1, Col: 17, Name: "inputdata", + Write: true} + screen = append(screen, newfield) + newfield = go3270.Field{Row: rows - 1, Col: cols - 1} // "stop" field + screen = append(screen, newfield) + + fieldValues := map[string]string{ + "termtype": termtype, + "rows": strconv.Itoa(rows), + "cols": strconv.Itoa(cols), + } + + if data != nil { + fieldValues["errormsg"] = fmt.Sprintf("You said: %s", data.(string)) + } + + resp, err := go3270.HandleScreenAlt( + screen, // the screen to display + nil, // (no) rules to enforce + fieldValues, // pre-populated values in fields + []go3270.AID{ // keys we accept -- validating + go3270.AIDEnter, + }, + []go3270.AID{ // keys we accept -- non-validating + go3270.AIDPF1, + go3270.AIDPF3, + }, + "errormsg", // name of field to put error messages in + rows-1, 18, // cursor coordinates + conn, // network connection + devinfo, // device info for alternate screen size support + ) + if err != nil { + return nil, nil, err + } + + switch resp.AID { + case go3270.AIDEnter: + // Re-run current transaction, echoing back input + return bigscreen, resp.Values["inputdata"], err + case go3270.AIDPF1: + // Go to default screen size transaction + return normalscreen, nil, nil + case go3270.AIDPF3: + // Exit + return nil, nil, nil + default: + // re-run current transaction + return bigscreen, nil, nil + } +} diff --git a/example5/example5.go b/example5/example5.go new file mode 100644 index 0000000..bcf7832 --- /dev/null +++ b/example5/example5.go @@ -0,0 +1,48 @@ +// This file is part of https://github.com/racingmars/go3270/ +// Copyright 2025 by Matthew R. Wilson, licensed under the MIT license. See +// LICENSE in the project root for license information. + +// Example 5 demonstrates support for larger-than-default alternate screen +// sizes in terminals that are larger than 24x80. + +package main + +import ( + "fmt" + "net" + + "github.com/racingmars/go3270" +) + +func main() { + ln, err := net.Listen("tcp", ":3270") + if err != nil { + panic(err) + } + fmt.Println("LISTENING ON PORT 3270 FOR CONNECTIONS") + fmt.Println("Press Ctrl-C to end server.") + for { + conn, err := ln.Accept() + if err != nil { + panic(err) + } + go handle(conn) + } +} + +// handle is the handler for individual user connections. +func handle(conn net.Conn) { + defer conn.Close() + + // Always begin new connection by negotiating the telnet options + devinfo, err := go3270.NegotiateTelnet(conn) + if err != nil { + fmt.Println(err) + return + } + + err = go3270.RunTransactions(conn, devinfo, bigscreen, nil) + if err != nil { + fmt.Println(err) + } +} diff --git a/example5/normalscreen.go b/example5/normalscreen.go new file mode 100644 index 0000000..0c6dee5 --- /dev/null +++ b/example5/normalscreen.go @@ -0,0 +1,103 @@ +// This file is part of https://github.com/racingmars/go3270/ +// Copyright 2025 by Matthew R. Wilson, licensed under the MIT license. See +// LICENSE in the project root for license information. + +package main + +import ( + "fmt" + "net" + "strconv" + + "github.com/racingmars/go3270" +) + +var normallayout = go3270.Screen{ + {Row: 0, Col: 28, Intense: true, Content: "3270 Screen Size Example"}, + + {Row: 2, Col: 0, + Content: "This screen is using the default size that all terminals support, 24x80."}, + {Row: 3, Col: 0, + Content: "But I know the following information about your particular terminal:"}, + + {Row: 4, Col: 0, Content: "Terminal Type . . ."}, + {Row: 4, Col: 21, Name: "termtype", Intense: true}, + + {Row: 5, Col: 0, Content: "Rows . . . . . . . ."}, + {Row: 5, Col: 21, Name: "rows", Intense: true}, + {Row: 5, Col: 28, Content: "(but currently using 24)"}, + + {Row: 6, Col: 0, Content: "Columns . . . . . ."}, + {Row: 6, Col: 21, Name: "cols", Intense: true}, + {Row: 6, Col: 28, Content: "(but currently using 80)"}, + + {Row: 8, Col: 0, Content: "To visit a large sized screen, press"}, + {Row: 8, Col: 37, Content: "PF1", Color: go3270.Yellow, Intense: true}, + + {Row: 9, Col: 0, Content: "To exit and disconnect, press"}, + {Row: 9, Col: 30, Content: "PF3", Color: go3270.Yellow, Intense: true}, + + // a blank field for error messages + {Row: 11, Col: 0, Intense: true, Color: go3270.Red, Name: "errormsg"}, +} + +func normalscreen(conn net.Conn, devinfo go3270.DevInfo, data any) ( + go3270.Tx, any, error) { + + rows, cols := devinfo.AltDimensions() + termtype := devinfo.TerminalType() + + // Make a local copy of the screen definition that we can append lines to. + screen := make(go3270.Screen, len(normallayout)) + copy(screen, normallayout) + + // We'll start writing "data lines" at row 13 up to 24 + for i := 13; i < 24; i++ { + newfield := go3270.Field{Row: i, Col: 0, + Content: fmt.Sprintf("This is data row %d.", i-12)} + if i == 23 { + newfield.Content += " (The last.)" + } + screen = append(screen, newfield) + + newfield = go3270.Field{Row: i, Col: 80 - 5, Content: "<**>"} + screen = append(screen, newfield) + } + + fieldValues := map[string]string{ + "termtype": termtype, + "rows": strconv.Itoa(rows), + "cols": strconv.Itoa(cols), + } + + // We can call the old HandleScreen(), or we could have used the new + // HandleScreenAlt() and provided a nil DevInfo. + resp, err := go3270.HandleScreen( + screen, // the screen to display + nil, // (no) rules to enforce + fieldValues, // pre-populated values in fields + nil, // keys we accept -- validating + []go3270.AID{ // keys we accept -- non-validating + go3270.AIDPF1, + go3270.AIDPF3, + }, + "errormsg", // name of field to put error messages in + 1, 1, // cursor coordinates + conn, // network connection + ) + if err != nil { + return nil, nil, err + } + + switch resp.AID { + case go3270.AIDPF1: + // Go to big screen size transaction + return bigscreen, nil, nil + case go3270.AIDPF3: + // Exit + return nil, nil, nil + default: + // re-run current transaction + return normalscreen, nil, nil + } +} diff --git a/looper.go b/looper.go index 765283a..d225201 100644 --- a/looper.go +++ b/looper.go @@ -86,9 +86,23 @@ type FieldRules struct { // fields pass validation, OR 2) the user presses a key in exitkeys. In all // other cases, HandleScreen will re-present the screen to the user again, // possibly with an error message set in the errorField field. +// +// For alternate screen support (larger than 24x80), use HandleScreenAlt(). func HandleScreen(screen Screen, rules Rules, values map[string]string, pfkeys, exitkeys []AID, errorField string, crow, ccol int, conn net.Conn) (Response, error) { + return HandleScreenAlt(screen, rules, values, pfkeys, exitkeys, errorField, + crow, ccol, conn, nil) +} + +// HandleScreenAlt is identical to HandleScreen, but writes to the "alternate" +// screen size provided by dev. To write a non-24-by-80 screen, use this +// HandleScreenAlt function with a non-nil dev. If dev is nil, the behavior is +// identical to HandleScreen, which is limited to 24x80 and will set larger +// terminals to the default 24x80 mode. +func HandleScreenAlt(screen Screen, rules Rules, values map[string]string, + pfkeys, exitkeys []AID, errorField string, crow, ccol int, + conn net.Conn, dev DevInfo) (Response, error) { // Save the original field values for any named fields to support // the MustChange rule. Also build a map of named fields. @@ -128,7 +142,7 @@ mainloop: } resp, err := ShowScreenOpts(screen, myValues, conn, - ScreenOpts{CursorRow: crow, CursorCol: ccol}) + ScreenOpts{CursorRow: crow, CursorCol: ccol, AltScreen: dev}) if err != nil { return resp, err } diff --git a/response.go b/response.go index ba6ab95..a46f247 100644 --- a/response.go +++ b/response.go @@ -6,9 +6,7 @@ package go3270 import ( "bytes" - "fmt" "net" - "os" ) // Response encapsulates data received from a 3270 client in response to the @@ -61,9 +59,11 @@ const ( AIDPA2 AID = 0x6E AIDPA3 AID = 0x6B AIDClear AID = 0x6D + + aidQueryResponse AID = 0x88 ) -func readResponse(c net.Conn, fm fieldmap) (Response, error) { +func readResponse(c net.Conn, fm fieldmap, dev DevInfo) (Response, error) { var r Response aid, err := readAID(c) if err != nil { @@ -79,7 +79,12 @@ func readResponse(c net.Conn, fm fieldmap) (Response, error) { return r, nil } - row, col, _, err := readPosition(c) + cols := 80 + if dev != nil { + _, cols = dev.altDimensions() + } + + row, col, _, err := readPosition(c, cols) if err != nil { return r, err } @@ -87,7 +92,7 @@ func readResponse(c net.Conn, fm fieldmap) (Response, error) { r.Row = row var fieldValues map[string]string - if fieldValues, err = readFields(c, fm); err != nil { + if fieldValues, err = readFields(c, fm, cols); err != nil { return r, err } @@ -114,7 +119,7 @@ func readAID(c net.Conn) (AID, error) { } } -func readPosition(c net.Conn) (row, col, addr int, err error) { +func readPosition(c net.Conn, cols int) (row, col, addr int, err error) { raw := make([]byte, 2) // Read two bytes @@ -128,8 +133,8 @@ func readPosition(c net.Conn) (row, col, addr int, err error) { // Decode the raw position addr = decodeBufAddr([2]byte{raw[0], raw[1]}) - col = addr % 80 - row = (addr - col) / 80 + col = addr % cols + row = (addr - col) / cols debugf("Got position bytes %02x %02x, decoded to %d\n", raw[0], raw[1], addr) @@ -137,7 +142,7 @@ func readPosition(c net.Conn) (row, col, addr int, err error) { return row, col, addr, nil } -func readFields(c net.Conn, fm fieldmap) (map[string]string, error) { +func readFields(c net.Conn, fm fieldmap, cols int) (map[string]string, error) { var infield bool var fieldpos int var fieldval bytes.Buffer @@ -174,7 +179,7 @@ func readFields(c net.Conn, fm fieldmap) (map[string]string, error) { fieldval = bytes.Buffer{} fieldpos = 0 - if _, _, fieldpos, err = readPosition(c); err != nil { + if _, _, fieldpos, err = readPosition(c, cols); err != nil { return nil, err } continue @@ -203,20 +208,13 @@ func handleField(addr int, value []byte, fm fieldmap, values map[string]string) } // decodeBufAddr decodes a raw 2-byte encoded buffer address and returns the -// integer value of the address (i.e. 0-1919) +// integer value of the address. func decodeBufAddr(raw [2]byte) int { - if decodes[raw[0]] > 254 { - fmt.Fprintf(os.Stderr, - "UNEXPECTED VALUE: decodeBufAddr got raw value of %02x %02x\n", - raw[0], raw[1]) - } - if decodes[raw[1]] > 254 { - fmt.Fprintf(os.Stderr, - "UNEXPECTED VALUE: decodeBufAddr got raw value of %02x %02x\n", - raw[0], raw[1]) + // 16-bit addressing + if raw[0]&0xc0 == 0 { + return int(raw[0])<<8 + int(raw[1]) } - hi := decodes[raw[0]] << 6 - lo := decodes[raw[1]] - return hi | lo + // 12-bit addressing + return int(raw[0]&0x3f)<<6 + int(raw[1]&0x3f) } diff --git a/screen.go b/screen.go index 552b678..040fe3e 100644 --- a/screen.go +++ b/screen.go @@ -98,6 +98,19 @@ type Screen []Field // ScreenOpts are the options that callers may set when sending a screen // to the 3270 client. type ScreenOpts struct { + // If AltScreen is non-nil, the screen will be written to the "alternate" + // screen size, which is the non-default (24x80) screen dimensions that + // the terminal supports (although for many terminals, the alternate + // screen is still just 24x80). If AltScreen is nil, the default (24x80) + // mode will be used. Never switch between AltScreen and normal screen + // (e.g. AltScreen = nil) unless NoClear is false. (That is, switching + // between default and alternate screen size or back requires a screen + // clear at the same time.) When AltScreen is nil, field positions in the + // screen must be within the 24x80 screen (so rows 0-23 and cols 0-79), + // when AltScreen is present, the field positions must be within the + // dimensions of the DevInfo.AltDimensions() values. + AltScreen DevInfo + // NoResponse will draw the screen and immediately return, without // waiting for any input data from the remote client. NoResponse bool @@ -107,12 +120,16 @@ type ScreenOpts struct { // the current screen. NoClear bool - // CursorRow sets the row (0-indexed) to position the cursor after - // sending the screen, when NoClear is false. Maximum value is 23. + // CursorRow sets the row (0-indexed) to position the cursor after sending + // the screen, when NoClear is false. When AltScreen is nil, maximum value + // is 23; otherwise, maximum is 1 less than the number of rows in + // AltScreen. CursorRow int // CursorCol sets the column (0-indexed) to position the cursor after - // sending the screen, when NoClear is false. Maximum value is 79. + // sending the screen, when NoClear is false. When AltScreen is nil, + // maximum value is 79; otherwise, maximum is 1 less than the number of + // columns in AltScreen. CursorCol int // PostSendCallback is a function that, if non-nil, will be called after @@ -159,13 +176,15 @@ type fieldmap map[int]string // invalid. That is to say, while waiting for a response, don't perform other // actions from another thread that could layout the user input fields // differently. -func ShowScreenOpts(screen Screen, values map[string]string, conn net.Conn, + +func ShowScreenOpts(screen Screen, + values map[string]string, conn net.Conn, opts ScreenOpts) (Response, error) { var resp Response fm, err := showScreenInternal(screen, values, opts.CursorRow, - opts.CursorCol, conn, !opts.NoClear) + opts.CursorCol, conn, !opts.NoClear, opts.AltScreen) if err != nil { return resp, err } @@ -178,7 +197,7 @@ func ShowScreenOpts(screen Screen, values map[string]string, conn net.Conn, } if !opts.NoResponse { - resp, err = readResponse(conn, fm) + resp, err = readResponse(conn, fm, opts.AltScreen) if err != nil { return resp, err } @@ -216,13 +235,22 @@ func ShowScreenNoResponse(screen Screen, values map[string]string, } func showScreenInternal(screen Screen, values map[string]string, - crow, ccol int, conn net.Conn, clear bool) (fieldmap, error) { + crow, ccol int, conn net.Conn, clear bool, dev DevInfo) (fieldmap, error) { + + rows, cols := 24, 80 + if dev != nil { + rows, cols = dev.altDimensions() + } var b bytes.Buffer var fm = make(fieldmap) // field buffer positions -> name if clear { - b.WriteByte(0xf5) // Erase/Write to terminal + if !(rows == 24 && cols == 80) { + b.WriteByte(0x7e) // Erase/Write Alternate to terminal + } else { + b.WriteByte(0xf5) // Erase/Write to terminal + } } else { b.WriteByte(0xf1) // Write to terminal } @@ -237,12 +265,12 @@ func showScreenInternal(screen Screen, values map[string]string, // Build the commands for each field on the screen for _, fld := range screen { - if fld.Row < 0 || fld.Row > 23 || fld.Col < 0 || fld.Col > 79 { + if fld.Row < 0 || fld.Row > rows-1 || fld.Col < 0 || fld.Col > cols-1 { // Invalid field position continue } - b.Write(sba(fld.Row, fld.Col)) + b.Write(sba(fld.Row, fld.Col, cols)) b.Write(buildField(fld)) // Use fld.Content, unless the field is named and appears in the @@ -262,7 +290,7 @@ func showScreenInternal(screen Screen, values map[string]string, // because we get the position of the field's first input position, // not the position of the field attribute byte). if fld.Write { - bufaddr := fld.Row*80 + fld.Col + bufaddr := fld.Row*cols + fld.Col fm[bufaddr+1] = fld.Name } } @@ -271,13 +299,13 @@ func showScreenInternal(screen Screen, values map[string]string, // caller-provided coordinates. if clear { // Set cursor position. Correct out-of-bounds values to 0. - if crow < 0 || crow > 23 { + if crow < 0 || crow > rows-1 { crow = 0 } - if ccol < 0 || ccol > 79 { + if ccol < 0 || ccol > cols-1 { ccol = 0 } - b.Write(ic(crow, ccol)) + b.Write(ic(crow, ccol, cols)) } b.Write([]byte{0xff, 0xef}) // Telnet IAC EOR @@ -292,10 +320,10 @@ func showScreenInternal(screen Screen, values map[string]string, } // sba is the "set buffer address" 3270 command. -func sba(row, col int) []byte { +func sba(row, col, cols int) []byte { result := make([]byte, 1, 3) result[0] = 0x11 // SBA - result = append(result, getpos(row, col)...) + result = append(result, getpos(row, col, cols)...) return result } @@ -372,20 +400,37 @@ func sfAttribute(write, intense, hidden, skip, numeric bool) byte { // ic is the "insert cursor" 3270 command. This function will include the // appropriate SBA command. -func ic(row, col int) []byte { +func ic(row, col, cols int) []byte { result := make([]byte, 0, 3) - result = append(result, sba(row, col)...) + result = append(result, sba(row, col, cols)...) result = append(result, 0x13) // IC return result } // getpos translates row and col to buffer address control characters. -func getpos(row, col int) []byte { - result := make([]byte, 2) - address := row*80 + col - hi := (address & 0xfc0) >> 6 - lo := address & 0x3f - result[0] = codes[hi] - result[1] = codes[lo] - return result +func getpos(row, col, cols int) []byte { + address := row*cols + col + + // Use 12-bit addressing if the buffer address fits in 12 bits + if address < 1<<12 { + hi := (address & 0xfc0) >> 6 + lo := address & 0x3f + return []byte{codes[hi], codes[lo]} + } + + // Otherwise, use 14-bit addressing. The library limits terminal size to + // fit within 14-bit addressing, because 16-bit addressing would require + // us to track state that the current API design doesn't lend itself to. + // Someday, perhaps in a v2 library version, we'll support absurdly large + // terminal sizes. But for now, 14 bits is as big as we can go. + hi := (address & 0x3f00) >> 8 + lo := address & 0xff + + // It's possible the low byte is 0xff, in which case we need to telnet- + // escape it. + if lo == 0xff { + return []byte{byte(hi), 0xff, byte(lo)} + } + + return []byte{byte(hi), byte(lo)} } diff --git a/telnet.go b/telnet.go index 7294492..ac7b1d8 100644 --- a/telnet.go +++ b/telnet.go @@ -1,48 +1,337 @@ // This file is part of https://github.com/racingmars/go3270/ -// Copyright 2020 by Matthew R. Wilson, licensed under the MIT license. See -// LICENSE in the project root for license information. +// Copyright 2020, 2025 by Matthew R. Wilson, licensed under the MIT license. +// See LICENSE in the project root for license information. package go3270 import ( + "errors" "net" "time" ) +// DevInfo provides information about the terminal that is connected. +// +// 3270 terminals operate at a default screen size of 24 rows that are 80 +// columns wide. The normal "Write/Erase" datastream command always writes to +// the default 24x80 buffer. But some terminals support more rows and/or +// columns, and the alternate sized buffer may be written to with the +// "Write/Erase Alternate" command. +type DevInfo interface { + // AltDimensions returns the number or rows and columns on the alternate + // screen size. + AltDimensions() (rows, cols int) + + // TerminalType reports the terminal-provided identification string. All + // modern tn3270 clients will report one of the IBM-3278 models (-2, -3, + // -4, or -5), or IBM-DYNAMIC if the alternate screen size isn't one of + // the fixed sizes of the 3278 models. This string is purely + // informational; the actual size of the alternate screen is available + // from AltDimensions(). + TerminalType() string + + // Private version of AltDimensions() so callers can't fake us out; only + // real implementations returned by NegotiateTelnet() will work. + altDimensions() (rows, cols int) +} + const ( - binary = 0 - send = 1 - se = 240 // f0 - sb = 250 // fa - will = 251 // fb - wont = 252 // fc - do = 253 // fd - dont = 254 // fe - iac = 255 // ff - terminalType = 24 // 18 - eoroption = 25 // 19 - eor = 239 // f1 + se = 240 // 0xf0 + sb = 250 // 0xfa + will = 251 // 0xfb + wont = 252 // 0xfc + do = 253 // 0xfd + dont = 254 // 0xfe + iac = 255 // 0xff + + // Options + + binaryOption = 0 + + eorOption = 25 // 0x19 + eor = 239 // 0xf1 + + terminalType = 24 // 0x18 + terminalTypeIs = 0 + terminalTypeSend = 1 ) -// NegotiateTelnet will naively (e.g. not checking client responses) negotiate -// the options necessary for tn3270 on a new telnet connection, conn. -func NegotiateTelnet(conn net.Conn) error { - conn.Write([]byte{iac, do, terminalType}) - conn.Write([]byte{iac, sb, terminalType, send, iac, se}) - conn.Write([]byte{iac, do, eoroption}) - conn.Write([]byte{iac, do, binary}) - conn.Write([]byte{iac, will, eoroption, iac, will, binary}) - flushConnection(conn, time.Second*5) +// ErrNo3270 indicates that the telnet client did not respond properly to the +// options negotiation that are expected for a tn3270 client. +var ErrNo3270 = errors.New("couldn't negotiate telnet options for tn3270") + +// ErrTelnetError indicates an unexpected response was encountered in the +// telnet protocol. +var ErrTelnetError = errors.New("telnet or 3270 protocol error") + +// ErrUnknownTerminal indicates the client did not identify itself as an +// IBM-3277, 3278, 3279, or IBM-DYNAMIC model. All modern tn3270 clients +// should report as IBM-3278 models or IBM-DYNAMIC. +var ErrUnknownTerminal = errors.New("unknown terminal type") + +var errOptionRejected = errors.New("option rejected") + +// NegotiateTelnet will negotiate the options necessary for tn3270 on a new +// telnet connection, conn. +func NegotiateTelnet(conn net.Conn) (DevInfo, error) { + + // Enable terminal type option + if _, err := conn.Write([]byte{iac, do, terminalType}); err != nil { + return nil, err + } + err := checkOptionResponse(conn, terminalType, do) + if err == errOptionRejected || err == ErrTelnetError { + return nil, ErrNo3270 + } else if err != nil { + return nil, err + } + + // Switch to the first available terminal type + conn.Write([]byte{iac, sb, terminalType, terminalTypeSend, iac, se}) + devtype, err := getTerminalType(conn) + if err == ErrTelnetError { + return nil, ErrNo3270 + } else if err != nil { + return nil, err + } + + // Request end of record mode + conn.Write([]byte{iac, do, eorOption}) + err = checkOptionResponse(conn, eorOption, do) + if err == errOptionRejected || err == ErrTelnetError { + return nil, ErrNo3270 + } else if err != nil { + return nil, err + } + + // Request binary mode + conn.Write([]byte{iac, do, binaryOption}) + err = checkOptionResponse(conn, binaryOption, do) + if err == errOptionRejected || err == ErrTelnetError { + return nil, ErrNo3270 + } else if err != nil { + return nil, err + } + + // Enter end of record mode + conn.Write([]byte{iac, will, eorOption}) + err = checkOptionResponse(conn, eorOption, will) + if err == errOptionRejected || err == ErrTelnetError { + return nil, ErrNo3270 + } else if err != nil { + return nil, err + } + + // Enter binary mode + conn.Write([]byte{iac, will, binaryOption}) + err = checkOptionResponse(conn, binaryOption, will) + if err == errOptionRejected || err == ErrTelnetError { + return nil, ErrNo3270 + } else if err != nil { + return nil, err + } + + devinfo, err := makeDeviceInfo(conn, devtype) + if err != nil { + return nil, err + } + + return devinfo, nil +} + +// checkOptionResponse will check for the client's "will/wont" (if mode is do) +// or "do/dont" (if mode is will) response. mode is the option command the +// server just sent, and option is the option code to check for. +func checkOptionResponse(conn net.Conn, option, mode byte) error { + var buf [3]byte + + var expectedYes, expectedNo byte + switch mode { + case do: + expectedYes = will + expectedNo = wont + case will: + expectedYes = do + expectedNo = dont + default: + return ErrTelnetError + } + + n, err := conn.Read(buf[:]) + if err != nil { + return err + } + if n < 3 || buf[0] != iac { + return ErrTelnetError + } + if buf[1] == expectedNo { + // Was the correct option rejected? + if buf[2] != option { + return ErrTelnetError + } + return errOptionRejected + } + if buf[1] != expectedYes { + return ErrTelnetError + } + + // We have "will" now. But for the right option? + if buf[2] != option { + return ErrTelnetError + } + + // All good, client accepted the option we requested. return nil } +// getTerminalType reads the response to a "send terminal type" option +// subfield command. +func getTerminalType(conn net.Conn) (string, error) { + var buf [100]byte + var termtype string + + n, err := conn.Read(buf[:]) + if err != nil { + return termtype, err + } + + // At a minimum, with a one-character terminal type name, we expect + // 7 bytes + if n < 7 { + return termtype, ErrTelnetError + } + + // We'll check the expected control bytes all in one go... + if buf[0] != iac || buf[1] != sb || buf[2] != terminalType || + buf[3] != terminalTypeIs || buf[n-2] != iac || buf[n-1] != se { + return termtype, ErrTelnetError + } + + // Everything looks good. The terminal type is an ASCII string between all + // the control/command bytes. + return string(buf[4 : n-2]), nil +} + +func makeDeviceInfo(conn net.Conn, termtype string) (DevInfo, error) { + // Known fixed size device types. All modern tn3270 clients should + // report as 3278, but we'll also include 3277 and 3279 just in case. + switch termtype { + case "IBM-3277-2", "IBM-3277-2-E", "IBM-3278-2", "IBM-3278-2-E", + "IBM-3279-2", "IBM-3279-2-E": + return &deviceInfo{24, 80, termtype}, nil + case "IBM-3278-3", "IBM-3278-3-E", "IBM-3279-3", "IBM-3279-3-E": + return &deviceInfo{32, 80, termtype}, nil + case "IBM-3278-4", "IBM-3278-4-E": + return &deviceInfo{43, 80, termtype}, nil + case "IBM-3278-5", "IBM-3278-5-E": + return &deviceInfo{27, 132, termtype}, nil + } + + // If it's not a fixed-size type, it should be IBM-DYNAMIC. If it isn't, + // we don't know how to deal with it. + if termtype != "IBM-DYNAMIC" { + return nil, ErrUnknownTerminal + } + + // For IBM-DYNAMIC, we need to discover the alternate screen size with + // a structured field query. + + // First, we perform an ERASE / WRITE ALTERNATE to clear the screen + // and put it in alternate screen mode. (EWA, reset WCC, telnet EOR) + if _, err := conn.Write([]byte{0x7e, 0xc3, 0xff, 0xef}); err != nil { + return nil, err + } + + // Now we need to send the Write Structured Field command (0xf3) with the + // "Read Partition - Query" structured field. Note that we're + // telnet-escaping the 0xff in the data, but the subfield length is the + // *unescaped* length (7). + if _, err := conn.Write([]byte{0xf3, 0, 7, 0x01, 0xff, 0xff, 0x02, + 0xff, 0xef}); err != nil { + return nil, err + } + + var aid [1]byte + n, err := conn.Read(aid[:]) + if err != nil { + return nil, err + } + if n != 1 || aid[0] != byte(aidQueryResponse) { + return nil, ErrTelnetError + } + + var rows, cols int + // There are an arbitrary number of query reply structured fields. We + // are only interested in the "Usable Area" SFID=0x81 QCODE=0x81 field, + // so we'll just consume any others. Consume all data until the EOR is + // received. + for { + // Two bytes are big-endian length. + buf, err := telnetReadN(conn, 2) + if err != nil { + return nil, err + } + if buf == nil { + // EOR. We're out of fields. + break + } + + var l int = int(buf[0])<<8 + int(buf[1]) + + // Field length includes the 2 length bytes + buf, err = telnetReadN(conn, l-2) + if err != nil { + return nil, err + } + if buf == nil { + return nil, ErrTelnetError + } + + // Note that because length isn't at the beginning, offsets in buf + // are 2 less than in the 3270 datastream documentation. + + if !(buf[0] == 0x81 && buf[1] == 0x81) { + // Not 'Usable Area' query reply + continue + } + + // A valid Usable Area reply will always include at least 18 (20 with + // length) bytes. + if l < 18 { + return nil, ErrTelnetError + } + + // big-endian two byte values + cols = int(buf[4])<<8 + int(buf[5]) + rows = int(buf[6])<<8 + int(buf[7]) + } + + if rows == 0 || cols == 0 { + // We got an IBM-DYNAMIC device type, but it didn't include a + // Usable Area query response. + return nil, ErrUnknownTerminal + } + + // We support 12- and 14-bit addressing. Using 16-bit addressing would + // require a mode change and the current API design doesn't support + // tracking the state necessary for that. + // + // We'll limit the reported screen size to what fits in 14-bit addressing + // by removing rows if necessary. + for rows*cols >= 1<<14 { + rows-- + } + + return &deviceInfo{rows, cols, termtype}, nil +} + // UnNegotiateTelnet will naively (e.g. not checking client responses) attempt // to restore the telnet options state to what it was before NegotiateTelnet() // was called. func UnNegotiateTelnet(conn net.Conn, timeout time.Duration) error { - conn.Write([]byte{iac, wont, eoroption, iac, wont, binary}) - conn.Write([]byte{iac, dont, binary}) - conn.Write([]byte{iac, dont, eoroption}) + conn.Write([]byte{iac, wont, eorOption, iac, wont, binaryOption}) + conn.Write([]byte{iac, dont, binaryOption}) + conn.Write([]byte{iac, dont, eorOption}) conn.Write([]byte{iac, dont, terminalType}) flushConnection(conn, timeout) return nil @@ -138,3 +427,48 @@ func telnetRead(c net.Conn, passEOR bool) (b byte, valid, isEor bool, err error) } } } + +// telnetReadN reads n unescaped, valid, non-EOR characters. The returned byte +// slice will always be length n (see special case below, though), unless +// error is non-nil, in which case the byte slice will be nil. Invalid or +// early EOR will return ErrTelnetError. +// +// AS A SPECIAL CASE, if the first byte read is EOR, then the returned byte +// slice AND error will be nil. +func telnetReadN(conn net.Conn, n int) ([]byte, error) { + buf := make([]byte, n) + for i := 0; i < n; i++ { + b, valid, isEor, err := telnetRead(conn, true) + if err != nil { + return nil, err + } + if i == 0 && isEor { + // If we're still on the first byte and it's EOR, return a + // non-error nil value. + return nil, nil + } + if !valid || isEor { + return nil, ErrTelnetError + } + buf[i] = b + } + + return buf, nil +} + +type deviceInfo struct { + rows, cols int + termtype string +} + +func (d *deviceInfo) AltDimensions() (rows, cols int) { + return d.rows, d.cols +} + +func (d *deviceInfo) TerminalType() string { + return d.termtype +} + +func (d *deviceInfo) altDimensions() (rows, cols int) { + return d.rows, d.cols +} diff --git a/transactions.go b/transactions.go index 7ab04eb..8243d94 100644 --- a/transactions.go +++ b/transactions.go @@ -7,30 +7,43 @@ package go3270 import "net" // Tx is a function that serves as one transaction in a go3270 application. -// The Tx function is called with the network connection to the client, and a -// "data" value provided by the previous transaction. Tx functions return the -// next transaction to run (or nil to indicate the RunTransactions() function -// should terminate), the data to pass into the next transaction, and any -// error. If the error is non-nil, the RunTransactions() function will -// terminate and return the err. A non-nil error is _not_ passed between -// transactions, it terminates transaction processing. -type Tx func(conn net.Conn, data any) (next Tx, newdata any, err error) +// The Tx function is called with the network connection to the client, the +// DevInfo for use with alternate screen writes, and a "data" value provided +// by the previous transaction. Tx functions return the next transaction to +// run (or nil to indicate the RunTransactions() function should terminate), +// the data to pass into the next transaction, and any error. If the error is +// non-nil, the RunTransactions() function will terminate and return the err. +// A non-nil error is _not_ passed between transactions, it terminates +// transaction processing. +type Tx func(conn net.Conn, dev DevInfo, data any) ( + next Tx, newdata any, err error) // RunTransactions begins running transaction functions, starting with the // initial transaction, until a transaction eventually returns nil for the // next transaction, or until a transaction function returns a non-nil error // value. data (which may be nil, if the initial transaction does not require // data) is passed in as the data to the initial transaction. -func RunTransactions(conn net.Conn, initial Tx, data any) error { +// +// dev is the DevInfo of the connected client, as obtained from +// NegotiateTelnet(). It is safe to pass a nil DevInfo, in which case all +// transactions will only be able to operate with the default 24x80 screen +// size. +func RunTransactions(conn net.Conn, dev DevInfo, initial Tx, + data any) error { + var next Tx var err error next = initial + if dev == nil { + dev = &deviceInfo{rows: 24, cols: 80, termtype: "DEFAULT"} + } + // We run transactions until there isn't a next transaction to run, or // an error. for { - next, data, err = next(conn, data) + next, data, err = next(conn, dev, data) if err != nil { // Error means we bail out and return the error to the caller. return err diff --git a/util.go b/util.go index 1691b23..a539ccc 100644 --- a/util.go +++ b/util.go @@ -23,8 +23,8 @@ func debugf(format string, a ...interface{}) { fmt.Fprintf(Debug, format, a...) } -// codes are the 3270 control character I/O codes, pre-computed as provided -// at http://www.tommysprinkle.com/mvs/P3270/iocodes.htm +// codes are the 3270 control character I/O codes for 12-bit addressing, +// from Figure D-1 of GA23-0059-00. (Figure C-1 in later editions.) var codes = []byte{0x40, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, 0x60, @@ -32,23 +32,6 @@ var codes = []byte{0x40, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0x6d, 0x6e, 0x6f, 0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f} -// decodes is the inverse of the above table; -1 is used in invalid positions -var decodes = []int{-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, -1, -1, -1, - -1, -1, -1, -1, -1, -1, 10, 11, 12, 13, 14, 15, 16, -1, -1, -1, -1, -1, - -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, -1, -1, -1, -1, -1, -1, - -1, -1, 42, 43, 44, 45, 46, 47, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - 58, 59, 60, 61, 62, 63, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 1, 2, - 3, 4, 5, 6, 7, 8, 9, -1, -1, -1, -1, -1, -1, -1, 17, 18, 19, 20, 21, 22, - 23, 24, 25, -1, -1, -1, -1, -1, -1, -1, -1, 34, 35, 36, 37, 38, 39, 40, - 41, -1, -1, -1, -1, -1, -1, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, -1, - -1, -1, -1, -1} - // AIDtoString returns a string representation of an AID key name. func AIDtoString(aid AID) string { switch aid {