From 78aaf72e84cbbfbb418d0b83f58c3e8bbe758df9 Mon Sep 17 00:00:00 2001 From: "Matthew R. Wilson" Date: Sun, 13 Apr 2025 22:04:54 -0700 Subject: [PATCH] Add RunTransactions() approach to application design --- .gitignore | 2 + README.md | 44 ++++---- example4/database.go | 96 ++++++++++++++++ example4/example4.go | 83 ++++++++++++++ example4/help.go | 68 ++++++++++++ example4/login.go | 257 +++++++++++++++++++++++++++++++++++++++++++ example4/mainmenu.go | 232 ++++++++++++++++++++++++++++++++++++++ go.mod | 2 +- transactions.go | 44 ++++++++ 9 files changed, 804 insertions(+), 24 deletions(-) create mode 100644 example4/database.go create mode 100644 example4/example4.go create mode 100644 example4/help.go create mode 100644 example4/login.go create mode 100644 example4/mainmenu.go create mode 100644 transactions.go diff --git a/.gitignore b/.gitignore index c156d32..a91d00a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ *.exe example1/example1 example2/example2 +example3/example3 +example4/example4 diff --git a/README.md b/README.md index 64338e6..a83b744 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,27 @@ Go 3270 Server Library This library allows you to write Go servers for tn3270 clients by building 3270 data streams from fields and processing the client's response to receive the attention keys and field values entered by users. -**The library is incomplete, likely buggy, and under heavy development: the interface is UNSTABLE until this notice is removed from this readme and version 1.0 is released.** +**Project status:** This library has been used by a small number of projects, and I believe the overall functionality is sound and relatively bug-free. Feedback is appreciated. At this point, I will try not to make breaking changes to the API, but I have not yet declared the library to be at v1.0, so I make no promises. + +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. + +**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. + +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]. @@ -15,28 +35,6 @@ Everything I know about 3270 data streams I learned from [Tommy Sprinkle's tutor [rfc854]: https://tools.ietf.org/html/rfc854 [telnetOptions]: https://www.iana.org/assignments/telnet-options/telnet-options.xhtml -Usage ------ - -See the example folders for a quick demonstration of using the library. example1 uses the lower-level function ShowScreen(), 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. - -Here's [a video introducing the library][introVideo] as well. - -[introVideo]: https://www.youtube.com/watch?v=h9XTjup5W5U - -Future Enhancements -------------------- - -I would like to add: - - - ~~Extended field attribute support (e.g. color).~~ **Done** - - Utility functions for easily laying out forms. - -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. - License ------- diff --git a/example4/database.go b/example4/database.go new file mode 100644 index 0000000..197575a --- /dev/null +++ b/example4/database.go @@ -0,0 +1,96 @@ +// 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. + +// This example demonstrates using the RunTransactions() approach to +// structuring a go3270 application. + +// This file contains a mock in-memory database for the purposes of our +// example application. + +package main + +import ( + "errors" + "sync" + "time" +) + +type User struct { + Username string + Password string + Name string + SignupDate time.Time +} + +type DB interface { + GetUser(username string) (User, error) + CreateUser(user User) (User, error) + UpdateUser(user User) (User, error) +} + +type dbstate struct { + lock sync.Mutex + users map[string]User +} + +func Connect() DB { + return &dbstate{ + users: make(map[string]User), + } +} + +var ErrUserExists = errors.New("username already exists") +var ErrNotFound = errors.New("record not found") + +func (db *dbstate) GetUser(username string) (User, error) { + db.lock.Lock() + defer db.lock.Unlock() + + user, ok := db.users[username] + if !ok { + return User{}, ErrNotFound + } + + return user, nil +} + +func (db *dbstate) CreateUser(user User) (User, error) { + db.lock.Lock() + defer db.lock.Unlock() + + if _, ok := db.users[user.Username]; ok { + return User{}, ErrUserExists + } + + user.SignupDate = time.Now().UTC().Truncate(time.Second) + + // Obviously in a real application you wouldn't store plaintext password, + // but this is a simple example for the purposes of demonstrating go3270, + // not demonstrating how to build a secure application. + db.users[user.Username] = user + + return user, nil +} + +func (db *dbstate) UpdateUser(user User) (User, error) { + db.lock.Lock() + defer db.lock.Unlock() + + olduser, ok := db.users[user.Username] + if !ok { + return User{}, ErrNotFound + } + + // Make sure original signup date is maintained + user.SignupDate = olduser.SignupDate + + // Carry over existing password if we aren't setting a new password + if user.Password == "" { + user.Password = olduser.Password + } + + db.users[user.Username] = user + + return user, nil +} diff --git a/example4/example4.go b/example4/example4.go new file mode 100644 index 0000000..2a3da28 --- /dev/null +++ b/example4/example4.go @@ -0,0 +1,83 @@ +// 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. + +// This example demonstrates using the RunTransactions() approach to +// structuring a go3270 application. + +package main + +import ( + "fmt" + "net" + "sync" + + "github.com/racingmars/go3270" +) + +type global struct { + usercount int // number of connected users + countlock sync.Mutex +} + +// session is the structure which holds global state for a user session of our +// application. The various Transactions will be methods on this struct. +type session struct { + db DB + user User + + // We can also share global state between users + g *global +} + +func main() { + // "Connect" to our database (for this example, it's just a mock in-memory + // database). + dbconn := Connect() + + gblstate := new(global) + + 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, dbconn, gblstate) + } +} + +// handle is the handler for individual user connections. +func handle(conn net.Conn, db DB, gblstate *global) { + defer conn.Close() + + // Create the state for this client's connection + state := session{ + db: db, + g: gblstate, + } + + // Add to the global user counter + gblstate.countlock.Lock() + gblstate.usercount++ + gblstate.countlock.Unlock() + + // When the session ends, reduce the user count + defer func() { + gblstate.countlock.Lock() + gblstate.usercount-- + gblstate.countlock.Unlock() + }() + + // Always begin new connection by negotiating the telnet options + go3270.NegotiateTelnet(conn) + err := go3270.RunTransactions(conn, state.login, nil) + if err != nil { + fmt.Println(err) + } +} diff --git a/example4/help.go b/example4/help.go new file mode 100644 index 0000000..abbaafb --- /dev/null +++ b/example4/help.go @@ -0,0 +1,68 @@ +// 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. + +// This example demonstrates using the RunTransactions() approach to +// structuring a go3270 application. + +// This file contains the help transaction generator. + +package main + +import ( + "net" + + "github.com/racingmars/go3270" +) + +var helpScreen = go3270.Screen{ + {Row: 0, Col: 45, Intense: true, Content: "Online Help"}, + + {Row: 2, Col: 0, + Content: "This help screen is an example of a transaction that"}, + {Row: 3, Col: 0, + Content: "can be used as the next transaction from many other transactions"}, + {Row: 4, Col: 0, Content: "and returns to the transaction that called it."}, + + {Row: 6, Col: 0, Content: "Press"}, + {Row: 6, Col: 6, Content: "PF3", Color: go3270.White, Intense: true}, + {Row: 6, Col: 10, + Content: "to return to the transaction from which you came."}, + + // Error message + {Row: 21, Col: 0, Name: "errormsg", Color: go3270.Red, Intense: true}, + + // Key legend + {Row: 23, Col: 1, Content: "F3=Exit"}, +} + +// The help transaction is an example of returning a closure over a parameter, +// in this case the desired next transaction. This allows us to use the same +// transaction from multiple other transactions and return to the requested +// transaction when the user leaves the help transaction. +// +// Any data passed to help will be passed back to the transaction it returns +// to. +// +// The help transaction has no need for any global or session state, so this +// 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) { + _, err := go3270.HandleScreen( + helpScreen, // the screen to display + nil, // (no) rules to enforce + nil, // pre-populated values in fields + []go3270.AID{go3270.AIDPF3}, // keys we accept + nil, + "errormsg", // name of field to put error messages in + 23, 79, // cursor coordinates + conn) + if err != nil { + return nil, nil, err + } + + // Any accepted key returns to the requested returnTransaction + return returnTransaction, data, nil + } +} diff --git a/example4/login.go b/example4/login.go new file mode 100644 index 0000000..4b91f5a --- /dev/null +++ b/example4/login.go @@ -0,0 +1,257 @@ +// 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. + +// This example demonstrates using the RunTransactions() approach to +// structuring a go3270 application. + +// This file contains the login and user registration transactions. + +package main + +import ( + "net" + + "github.com/racingmars/go3270" +) + +const loginUsername = "username" +const loginPassword = "password" +const loginPasswordConf = "password confirmation" +const loginName = "name" +const loginErr = "errormsg" + +var loginScreen = go3270.Screen{ + {Row: 0, Col: 37, Intense: true, Content: "Logon"}, + {Row: 2, Col: 0, + Content: "Welcome to the go3270 example application. Please log on."}, + + // Username + {Row: 4, Col: 0, Content: "Username . . .", Color: go3270.Green}, + {Row: 4, Col: 15, Name: loginUsername, Write: true, + Highlighting: go3270.Underscore, Color: go3270.Turquoise}, + {Row: 4, Col: 24, Autoskip: true}, // field "stop" character + + // Password + {Row: 5, Col: 0, Content: "Password . . .", Color: go3270.Green}, + {Row: 5, Col: 15, Name: loginPassword, Write: true, Hidden: true}, + {Row: 5, Col: 79}, // field "stop" character + + // Registration instructions + {Row: 7, Col: 0, Content: "If you don't yet have an account, press"}, + {Row: 7, Col: 40, Content: "PF5", Color: go3270.White, Intense: true}, + {Row: 7, Col: 44, Content: "to register a new account."}, + + // Error message + {Row: 21, Col: 0, Name: loginErr, Color: go3270.Red, Intense: true}, + + // Key legend + {Row: 23, Col: 1, Content: "F1=Help"}, + {Row: 23, Col: 14, Content: "F3=Exit"}, + {Row: 23, Col: 27, Content: "F5=Register"}, +} + +var loginScreenRules = go3270.Rules{ + loginUsername: {Validator: go3270.NonBlank}, + loginPassword: {Validator: go3270.NonBlank, Reset: true}, +} + +// 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) { + + fieldValues := make(map[string]string) + + if data != nil { + if errmsg, ok := data.(string); ok { + fieldValues[loginErr] = errmsg + } + } + + resp, err := go3270.HandleScreen( + loginScreen, // the screen to display + loginScreenRules, // the rules to enforce + fieldValues, // pre-populated values in fields + []go3270.AID{go3270.AIDEnter}, // keys we accept -- validating + []go3270.AID{ // keys we accept -- non-validating + go3270.AIDPF1, + go3270.AIDPF3, + go3270.AIDPF5, + go3270.AIDClear}, + loginErr, // name of field to put error messages in + 4, 16, // cursor coordinates + conn) + if err != nil { + return nil, nil, err + } + + switch resp.AID { + case go3270.AIDClear: + // re-run the transaction with empty values + return sess.login, nil, nil + case go3270.AIDPF1: + // Display help transaction, then return to this transaction + return help(sess.login), nil, nil + case go3270.AIDPF3: + // User wants to quit; return no next transaction + return nil, nil, nil + case go3270.AIDPF5: + // Send user to registration transaction + return sess.newuser, nil, nil + } + + // If we didn't get one of the other allowed keys, try to log the user in. + username := resp.Values[loginUsername] + password := resp.Values[loginPassword] + + user, err := sess.db.GetUser(username) + // NOTE: this is just an example application. Obviously in a real + // application, password hashing would be in place. + if err != nil || user.Password != password { + // If we couldn't get the username from the DB, or if the password + // doesn't match, re-run the login transaction with an error + // message displayed. + + return sess.login, "Username or password not valid.", nil + } + + // Login was successful. Update the session state to the logged-in user. + sess.user = user + + return sess.mainmenu, nil, nil +} + +var newuserScreen = go3270.Screen{ + {Row: 0, Col: 30, Intense: true, Content: "New User Registration"}, + {Row: 2, Col: 0, + Content: "Please provide your user registration details."}, + + // Username + {Row: 4, Col: 0, Content: "Username . . .", Color: go3270.Green}, + {Row: 4, Col: 15, Name: loginUsername, Write: true, + Highlighting: go3270.Underscore, Color: go3270.Turquoise}, + {Row: 4, Col: 24, Autoskip: true}, // field "stop" character + + // Password + {Row: 5, Col: 0, Content: "Password . . .", Color: go3270.Green}, + {Row: 5, Col: 15, Name: loginPassword, Write: true, Hidden: true}, + {Row: 5, Col: 79, Autoskip: true}, // field "stop" character + + // Password + {Row: 6, Col: 0, Content: "Confirm Pass .", Color: go3270.Green}, + {Row: 6, Col: 15, Name: loginPasswordConf, Write: true, Hidden: true}, + {Row: 6, Col: 79, Autoskip: true}, // field "stop" character + + // Name + {Row: 7, Col: 0, Content: "Name . . . . .", Color: go3270.Green}, + {Row: 7, Col: 15, Name: loginName, Write: true, + Highlighting: go3270.Underscore, Color: go3270.Turquoise}, + {Row: 7, Col: 46, Autoskip: true}, // field "stop" character + + // Error message + {Row: 21, Col: 0, Name: loginErr, Color: go3270.Red, Intense: true}, + + // Key legend + {Row: 23, Col: 1, Content: "F1=Help"}, + {Row: 23, Col: 14, Content: "F3=Exit"}, +} + +var newuserScreenRules = go3270.Rules{ + loginUsername: {Validator: go3270.NonBlank}, + loginPassword: {Validator: go3270.NonBlank, Reset: true}, + loginPasswordConf: {Validator: go3270.NonBlank, Reset: true}, +} + +// newuserData is the data that can be passed into the newuser transaction +type newuserData struct { + username string + name string + errmsg string +} + +func (sess *session) newuser(conn net.Conn, data any) (go3270.Tx, any, error) { + + fieldValues := make(map[string]string) + + if data != nil { + if newdata, ok := data.(newuserData); ok { + fieldValues[loginUsername] = newdata.username + fieldValues[loginName] = newdata.name + fieldValues[loginErr] = newdata.errmsg + } + } + + resp, err := go3270.HandleScreen( + newuserScreen, // the screen to display + newuserScreenRules, // the rules to enforce + fieldValues, // pre-populated values in fields + []go3270.AID{go3270.AIDEnter}, // keys we accept -- validating + []go3270.AID{ // keys we accept -- non-validating + go3270.AIDPF1, + go3270.AIDPF3, + go3270.AIDClear, + }, + loginErr, // name of field to put error messages in + 4, 16, // cursor coordinates + conn) + if err != nil { + return nil, nil, err + } + + switch resp.AID { + case go3270.AIDClear: + // Re-run transaction with empty values + return sess.newuser, nil, nil + case go3270.AIDPF1: + // Display help screen, returning to this transaction after + return help(sess.newuser), nil, nil + case go3270.AIDPF3: + // User wants to cancel registration, go back to login screen. + return sess.login, nil, nil + } + + // Otherwise, we'll try creating the user. + + username := resp.Values[loginUsername] + password := resp.Values[loginPassword] + passwordConf := resp.Values[loginPasswordConf] + name := resp.Values[loginName] + + if password != passwordConf { + // Re-run the newuser transaction with an error message + return sess.newuser, newuserData{ + username: username, + name: name, + errmsg: "Passwords do not match.", + }, nil + } + + user, err := sess.db.CreateUser(User{ + Username: username, + Password: password, + Name: name, + }) + + if err == ErrUserExists { + // Re-run the newuser transaction with an error message + return sess.newuser, newuserData{ + username: username, + name: name, + errmsg: "Username already exists; please choose a new one.", + }, nil + } + + if err != nil { + return sess.newuser, newuserData{ + username: username, + name: name, + errmsg: "Unknown error creating new user.", + }, nil + } + + // Success! We'll stick the new user in the session state like login does + // and go to the main menu. + sess.user = user + + return sess.mainmenu, nil, nil +} diff --git a/example4/mainmenu.go b/example4/mainmenu.go new file mode 100644 index 0000000..3f8390a --- /dev/null +++ b/example4/mainmenu.go @@ -0,0 +1,232 @@ +// 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. + +// This example demonstrates using the RunTransactions() approach to +// structuring a go3270 application. + +// This file contains the main menu and the example placeholder features +// of the application. + +package main + +import ( + "net" + "strconv" + "strings" + "time" + + "github.com/racingmars/go3270" +) + +const mainmenuOption = "option" +const mainmenuUsername = "username" +const mainmenuTime = "time" +const mainmenuUsers = "users" +const mainmenuError = "errormsg" + +var mainmenuScreen = go3270.Screen{ + {Row: 0, Col: 31, Intense: true, Content: "Main Menu"}, + + // Option + {Row: 1, Col: 0, Content: "Option ===>", Color: go3270.Green}, + {Row: 1, Col: 12, Name: mainmenuOption, Write: true, + Highlighting: go3270.Underscore, Color: go3270.Turquoise}, + {Row: 1, Col: 79, Autoskip: true}, // field "stop" character + + // Info + {Row: 3, Col: 57, Content: "User ID . :", Color: go3270.Green}, + {Row: 3, Col: 69, Name: mainmenuUsername, Color: go3270.Turquoise}, + {Row: 4, Col: 57, Content: "Time. . . :", Color: go3270.Green}, + {Row: 4, Col: 69, Name: mainmenuTime, Color: go3270.Turquoise}, + {Row: 5, Col: 57, Content: "Users . . :", Color: go3270.Green}, + {Row: 5, Col: 69, Name: mainmenuUsers, Color: go3270.Turquoise}, + + // Options + {Row: 3, Col: 0, Content: "1", Color: go3270.White}, + {Row: 3, Col: 3, Content: "Feature 1", + Color: go3270.Turquoise, Intense: true}, + {Row: 3, Col: 17, Content: "A very cool feature", Color: go3270.Green}, + + {Row: 4, Col: 0, Content: "2", Color: go3270.White}, + {Row: 4, Col: 3, Content: "Feature 2", + Color: go3270.Turquoise, Intense: true}, + {Row: 4, Col: 17, Content: "Another neat feature", Color: go3270.Green}, + + {Row: 5, Col: 0, Content: "3", Color: go3270.White}, + {Row: 5, Col: 3, Content: "Feature 3", + Color: go3270.Turquoise, Intense: true}, + {Row: 5, Col: 17, Content: "This one's a boring feature", + Color: go3270.Green}, + + {Row: 7, Col: 5, Content: "Enter", Color: go3270.Green}, + {Row: 7, Col: 11, Content: "X", Color: go3270.Turquoise, Intense: true}, + {Row: 7, Col: 13, Content: "to log off and exit.", Color: go3270.Green}, + + // Error message + {Row: 21, Col: 0, Name: mainmenuError, Color: go3270.Red, Intense: true}, + + // Key legend + {Row: 23, Col: 1, Content: "F1=Help"}, + {Row: 23, Col: 14, Content: "F3=Exit"}, + {Row: 23, Col: 27, Content: "F5=Refresh"}, +} + +type mainmenuData struct { + option string // pre-populated option field value + errmsg string // error message to display +} + +// 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) { + + fieldValues := make(map[string]string) + + if data != nil { + if d, ok := data.(mainmenuData); ok { + fieldValues[mainmenuOption] = d.option + fieldValues[mainmenuError] = d.errmsg + } + } + + fieldValues[mainmenuUsername] = strings.ToUpper(sess.user.Username) + fieldValues[mainmenuTime] = time.Now().UTC().Format("15:04:05") + fieldValues[mainmenuUsers] = strconv.Itoa(sess.g.usercount) + + resp, err := go3270.HandleScreen( + mainmenuScreen, // the screen to display + nil, // (no) rules to enforce + fieldValues, // pre-populated values in fields + []go3270.AID{go3270.AIDEnter}, // keys we accept -- validating + []go3270.AID{ // keys we accept -- non-validating + go3270.AIDPF1, + go3270.AIDPF3, + go3270.AIDPF5, + }, + mainmenuError, // name of field to put error messages in + 1, 13, // cursor coordinates + conn) + if err != nil { + return nil, nil, err + } + + switch resp.AID { + case go3270.AIDPF1: + // Display help, then return to this transaction + return help(sess.mainmenu), nil, nil + case go3270.AIDPF3: + // Exit + return nil, nil, nil + case go3270.AIDPF5: + // Refresh + return sess.mainmenu, nil, nil + } + + // Handle the available options: + switch resp.Values[mainmenuOption] { + case "1": + return sess.exampleFeature, + "This is feature 1, a very cool feature.", nil + case "2": + return sess.exampleFeature, + "This is feature 2, another neat feature.", nil + case "3": + return sess.exampleFeature, + "This is feature 3, which is a boring one.", nil + case "x", "X": + // As if user hit PF3; exit + return nil, nil, nil + case "": + // As if user hit PF5, refresh + return sess.mainmenu, nil, nil + default: + // Run the transaction again with an error message + return sess.mainmenu, + mainmenuData{ + option: resp.Values[mainmenuOption], + errmsg: "Unknown option", + }, nil + } +} + +const featureMessage = "message" + +var exampleScreen = go3270.Screen{ + {Row: 0, Col: 30, Intense: true, Content: "Application Feature"}, + + // Info -- replicate the data shown on the main menu + {Row: 3, Col: 57, Content: "User ID . :", Color: go3270.Green}, + {Row: 3, Col: 69, Name: mainmenuUsername, Color: go3270.Turquoise}, + {Row: 4, Col: 57, Content: "Time. . . :", Color: go3270.Green}, + {Row: 4, Col: 69, Name: mainmenuTime, Color: go3270.Turquoise}, + {Row: 5, Col: 57, Content: "Users . . :", Color: go3270.Green}, + {Row: 5, Col: 69, Name: mainmenuUsers, Color: go3270.Turquoise}, + + // Feature-specific message + {Row: 12, Col: 0, Name: featureMessage}, + + {Row: 14, Col: 0, Content: "Press"}, + {Row: 14, Col: 6, Content: "PF3", Intense: true, Color: go3270.White}, + {Row: 14, Col: 10, Content: "to return to the main menu."}, + + // Error message + {Row: 21, Col: 0, Name: mainmenuError, Color: go3270.Red, Intense: true}, + + // Key legend + {Row: 23, Col: 1, Content: "F1=Help"}, + {Row: 23, Col: 14, Content: "F3=Exit"}, + {Row: 23, Col: 27, Content: "F5=Refresh"}, +} + +// 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) { + + fieldValues := make(map[string]string) + + if data != nil { + if message, ok := data.(string); ok { + fieldValues[featureMessage] = message + } + } + + fieldValues[mainmenuUsername] = strings.ToUpper(sess.user.Username) + fieldValues[mainmenuTime] = time.Now().UTC().Format("15:04:05") + fieldValues[mainmenuUsers] = strconv.Itoa(sess.g.usercount) + + resp, err := go3270.HandleScreen( + exampleScreen, // 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, + go3270.AIDPF5, + }, + mainmenuError, // name of field to put error messages in + 23, 79, // cursor coordinates + conn) + if err != nil { + return nil, nil, err + } + + switch resp.AID { + case go3270.AIDPF1: + // Display help, then return to this transaction + return help(sess.exampleFeature), data, nil + case go3270.AIDPF3: + // Exit + return sess.mainmenu, nil, nil + case go3270.AIDPF5: + // Refresh + return sess.exampleFeature, data, nil + } + + // ...there shouldn't be any actions not handled above. Just in case, + // we'll just re-run this transaction as if refresh was hit. + return sess.exampleFeature, data, nil +} diff --git a/go.mod b/go.mod index 37f84cf..2b1cb15 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/racingmars/go3270 -go 1.13 +go 1.18 diff --git a/transactions.go b/transactions.go new file mode 100644 index 0000000..7ab04eb --- /dev/null +++ b/transactions.go @@ -0,0 +1,44 @@ +// 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 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) + +// 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 { + var next Tx + var err error + + next = initial + + // We run transactions until there isn't a next transaction to run, or + // an error. + for { + next, data, err = next(conn, data) + if err != nil { + // Error means we bail out and return the error to the caller. + return err + } + + if next == nil { + // nil next transaction means we're done. + return nil + } + } +}