From 0449737607325041e28bf981ecc30eb1feeceb46 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Wed, 16 Jun 2021 21:55:45 -0700 Subject: [PATCH] License Key Registration with ECDSA JWT Tokens * New command-line tool: doodle-admin for signing license keys for users. Includes functions to initialize a keypair, sign license keys and validate existing keys. * The Main Menu screen shows a blue "Register Game" button in the bottom right corner of the screen, for unregistered users only. * In Edit Mode, there is a "Help -> Register" menu item that opens the License Window. * The License UI Window lets the user select the license.key file to register the game with. If registered, a copy of the key is placed in Doodle's profile directory and the licensee name/email is shown in the License UI window. * Unregistered games will show the word "(shareware)" next to the title screen version number and Edit Mode status bar. * No restrictions are yet placed on free versions of the game. --- cmd/doodad/main.go | 4 +- cmd/doodle-admin/command/key.go | 44 +++++ cmd/doodle-admin/command/sign.go | 73 ++++++++ cmd/doodle-admin/command/verify.go | 59 +++++++ cmd/doodle-admin/main.go | 59 +++++++ cmd/doodle/main.go | 3 +- pkg/balance/theme.go | 2 + pkg/branding/branding.go | 2 +- pkg/doodle.go | 8 - pkg/editor_ui.go | 53 ++++-- pkg/license/admin.go | 100 +++++++++++ pkg/license/config.go | 25 +++ pkg/license/license.go | 103 +++++++++++ pkg/main_scene.go | 53 +++++- pkg/windows/license_key.go | 275 +++++++++++++++++++++++++++++ 15 files changed, 833 insertions(+), 30 deletions(-) create mode 100644 cmd/doodle-admin/command/key.go create mode 100644 cmd/doodle-admin/command/sign.go create mode 100644 cmd/doodle-admin/command/verify.go create mode 100644 cmd/doodle-admin/main.go create mode 100644 pkg/license/admin.go create mode 100644 pkg/license/config.go create mode 100644 pkg/license/license.go create mode 100644 pkg/windows/license_key.go diff --git a/cmd/doodad/main.go b/cmd/doodad/main.go index 5b90fc0..91e9c39 100644 --- a/cmd/doodad/main.go +++ b/cmd/doodad/main.go @@ -9,8 +9,8 @@ import ( "time" "git.kirsle.net/apps/doodle/cmd/doodad/commands" - "git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/branding" + "git.kirsle.net/apps/doodle/pkg/license" "github.com/urfave/cli/v2" ) @@ -32,7 +32,7 @@ func main() { app.Usage = "command line interface for Doodle" var freeLabel string - if balance.FreeVersion { + if !license.IsRegistered() { freeLabel = " (shareware)" } diff --git a/cmd/doodle-admin/command/key.go b/cmd/doodle-admin/command/key.go new file mode 100644 index 0000000..cb270e4 --- /dev/null +++ b/cmd/doodle-admin/command/key.go @@ -0,0 +1,44 @@ +package command + +import ( + "git.kirsle.net/apps/doodle/pkg/license" + "git.kirsle.net/apps/doodle/pkg/log" + "github.com/urfave/cli/v2" +) + +// Key a license key for Sketchy Maze. +var Key *cli.Command + +func init() { + Key = &cli.Command{ + Name: "key", + Usage: "generate an admin ECDSA signing key", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "public", + Usage: "Filename to write the public key to (.pem)", + Required: true, + }, + &cli.StringFlag{ + Name: "private", + Usage: "Filename to write the private key to (.pem)", + Required: true, + }, + }, + Action: func(c *cli.Context) error { + key, err := license.AdminGenerateKeys() + if err != nil { + return cli.Exit(err.Error(), 1) + } + + err = license.AdminWriteKeys(key, c.String("private"), c.String("public")) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + log.Info("Written private key: %s", c.String("private")) + log.Info("Written public key: %s", c.String("public")) + return nil + }, + } +} diff --git a/cmd/doodle-admin/command/sign.go b/cmd/doodle-admin/command/sign.go new file mode 100644 index 0000000..a14fd83 --- /dev/null +++ b/cmd/doodle-admin/command/sign.go @@ -0,0 +1,73 @@ +package command + +import ( + "fmt" + "io/ioutil" + + "git.kirsle.net/apps/doodle/pkg/license" + "git.kirsle.net/apps/doodle/pkg/log" + "github.com/urfave/cli/v2" +) + +// Sign a license key for Sketchy Maze. +var Sign *cli.Command + +func init() { + Sign = &cli.Command{ + Name: "sign", + Usage: "sign a license key for the paid version of Sketchy Maze.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "key", + Aliases: []string{"k"}, + Usage: "Private key .pem file for signing", + Required: true, + }, + &cli.StringFlag{ + Name: "name", + Aliases: []string{"n"}, + Usage: "User name for certificate", + Required: true, + }, + &cli.StringFlag{ + Name: "email", + Aliases: []string{"e"}, + Usage: "User email address", + Required: true, + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "Output file, default outputs to console", + }, + }, + Action: func(c *cli.Context) error { + key, err := license.AdminLoadPrivateKey(c.String("key")) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + reg := license.Registration{ + Name: c.String("name"), + Email: c.String("email"), + } + + result, err := license.AdminSignRegistration(key, reg) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + // Writing to an output file? + if output := c.String("output"); output != "" { + log.Info("Write to: %s", output) + if err := ioutil.WriteFile(output, []byte(result), 0644); err != nil { + return cli.Exit(err, 1) + } + } else { + fmt.Println(result) + } + + return nil + }, + } +} diff --git a/cmd/doodle-admin/command/verify.go b/cmd/doodle-admin/command/verify.go new file mode 100644 index 0000000..e5fa123 --- /dev/null +++ b/cmd/doodle-admin/command/verify.go @@ -0,0 +1,59 @@ +package command + +import ( + "io/ioutil" + "time" + + "git.kirsle.net/apps/doodle/pkg/license" + "git.kirsle.net/apps/doodle/pkg/log" + "github.com/urfave/cli/v2" +) + +// Verify a license key for Sketchy Maze. +var Verify *cli.Command + +func init() { + Verify = &cli.Command{ + Name: "verify", + Usage: "check the signature on a license key", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "key", + Aliases: []string{"k"}, + Usage: "Public key .pem file that signed the JWT", + Required: true, + }, + &cli.StringFlag{ + Name: "filename", + Aliases: []string{"f"}, + Usage: "File name of the license file to validate", + Required: true, + }, + }, + Action: func(c *cli.Context) error { + key, err := license.AdminLoadPublicKey(c.String("key")) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + jwt, err := ioutil.ReadFile(c.String("filename")) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + reg, err := license.Validate(key, string(jwt)) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + log.Info("Registration valid") + log.Info(" Name: %s", reg.Name) + log.Info(" Email: %s", reg.Email) + log.Info(" Issued: %s", time.Unix(reg.IssuedAt, 0)) + log.Info(" NBF: %s", time.Unix(reg.NotBefore, 0)) + log.Info("Raw:\n%+v", reg) + + return nil + }, + } +} diff --git a/cmd/doodle-admin/main.go b/cmd/doodle-admin/main.go new file mode 100644 index 0000000..38bf016 --- /dev/null +++ b/cmd/doodle-admin/main.go @@ -0,0 +1,59 @@ +// doodle-admin performs secret admin tasks like generating license keys. +package main + +import ( + "fmt" + "log" + "os" + "sort" + "time" + + "git.kirsle.net/apps/doodle/cmd/doodle-admin/command" + "git.kirsle.net/apps/doodle/pkg/branding" + "github.com/urfave/cli/v2" +) + +// Build variables. +var ( + Build = "N/A" + BuildDate string +) + +func init() { + if BuildDate == "" { + BuildDate = time.Now().Format(time.RFC3339) + } +} + +func main() { + app := cli.NewApp() + app.Name = "doodle-admin" + app.Usage = "Admin tasks for Sketchy Maze." + + app.Version = fmt.Sprintf("%s build %s. Built on %s", + branding.Version, + Build, + BuildDate, + ) + + app.Flags = []cli.Flag{ + &cli.BoolFlag{ + Name: "debug, d", + Usage: "enable debug level logging", + }, + } + + app.Commands = []*cli.Command{ + command.Key, + command.Sign, + command.Verify, + } + + sort.Sort(cli.FlagsByName(app.Flags)) + sort.Sort(cli.CommandsByName(app.Commands)) + + err := app.Run(os.Args) + if err != nil { + log.Fatal(err) + } +} diff --git a/cmd/doodle/main.go b/cmd/doodle/main.go index 4320340..5bd8f88 100644 --- a/cmd/doodle/main.go +++ b/cmd/doodle/main.go @@ -15,6 +15,7 @@ import ( "git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/bindata" "git.kirsle.net/apps/doodle/pkg/branding" + "git.kirsle.net/apps/doodle/pkg/license" "git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/shmem" "git.kirsle.net/apps/doodle/pkg/sound" @@ -51,7 +52,7 @@ func main() { app.Usage = fmt.Sprintf("%s - %s", branding.AppName, branding.Summary) var freeLabel string - if balance.FreeVersion { + if !license.IsRegistered() { freeLabel = " (shareware)" } diff --git a/pkg/balance/theme.go b/pkg/balance/theme.go index e77c912..f157a87 100644 --- a/pkg/balance/theme.go +++ b/pkg/balance/theme.go @@ -127,7 +127,9 @@ func init() { ButtonDanger.HoverForeground = ButtonPrimary.Foreground ButtonBabyBlue.Background = render.RGBA(0, 153, 255, 255) + ButtonBabyBlue.Foreground = render.White ButtonBabyBlue.HoverBackground = render.RGBA(0, 220, 255, 255) + ButtonBabyBlue.HoverForeground = render.White ButtonPink.Background = render.RGBA(255, 153, 255, 255) ButtonPink.HoverBackground = render.RGBA(255, 220, 255, 255) diff --git a/pkg/branding/branding.go b/pkg/branding/branding.go index 289fafb..fea84c6 100644 --- a/pkg/branding/branding.go +++ b/pkg/branding/branding.go @@ -4,7 +4,7 @@ package branding const ( AppName = "Sketchy Maze" Summary = "A drawing-based maze game" - Version = "0.6.1-alpha" + Version = "0.6.1" Website = "https://www.sketchymaze.com" Copyright = "2021 Noah Petherbridge" diff --git a/pkg/doodle.go b/pkg/doodle.go index 375a466..0c14a18 100644 --- a/pkg/doodle.go +++ b/pkg/doodle.go @@ -233,11 +233,6 @@ func (d *Doodle) NewMap() { // NewDoodad loads a new Doodad in Edit Mode. func (d *Doodle) NewDoodad(size int) { - if balance.FreeVersion { - d.Flash("Doodad editor is not available in your version of the game.") - return - } - log.Info("Starting a new doodad") scene := &EditorScene{ DrawingType: enum.DoodadDrawing, @@ -262,9 +257,6 @@ func (d *Doodle) EditDrawing(filename string) error { log.Info("is a Level type") scene.DrawingType = enum.LevelDrawing case ".doodad": - if balance.FreeVersion { - return fmt.Errorf("Doodad editor not supported in your version of the game") - } scene.DrawingType = enum.DoodadDrawing default: return fmt.Errorf("file extension '%s' doesn't indicate its drawing type", ext) diff --git a/pkg/editor_ui.go b/pkg/editor_ui.go index ccd2bb4..8894947 100644 --- a/pkg/editor_ui.go +++ b/pkg/editor_ui.go @@ -10,6 +10,7 @@ import ( "git.kirsle.net/apps/doodle/pkg/drawtool" "git.kirsle.net/apps/doodle/pkg/enum" "git.kirsle.net/apps/doodle/pkg/level" + "git.kirsle.net/apps/doodle/pkg/license" "git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/native" "git.kirsle.net/apps/doodle/pkg/uix" @@ -51,6 +52,7 @@ type EditorUI struct { layersWindow *ui.Window publishWindow *ui.Window filesystemWindow *ui.Window + licenseWindow *ui.Window // Palette window. Palette *ui.Window @@ -508,24 +510,22 @@ func (u *EditorUI) SetupMenuBar(d *Doodle) *ui.MenuBar { d.GotoNewMenu() }) }) - if !balance.FreeVersion { - fileMenu.AddItem("New doodad", func() { - u.Scene.ConfirmUnload(func() { - d.Prompt("Doodad size [100]>", func(answer string) { - size := balance.DoodadSize - if answer != "" { - i, err := strconv.Atoi(answer) - if err != nil { - d.Flash("Error: Doodad size must be a number.") - return - } - size = i + fileMenu.AddItem("New doodad", func() { + u.Scene.ConfirmUnload(func() { + d.Prompt("Doodad size [100]>", func(answer string) { + size := balance.DoodadSize + if answer != "" { + i, err := strconv.Atoi(answer) + if err != nil { + d.Flash("Error: Doodad size must be a number.") + return } - d.NewDoodad(size) - }) + size = i + } + d.NewDoodad(size) }) }) - } + }) fileMenu.AddItemAccel("Save", "Ctrl-S*", func() { if u.Scene.filename != "" { saveFunc(u.Scene.filename) @@ -688,6 +688,27 @@ func (u *EditorUI) SetupMenuBar(d *Doodle) *ui.MenuBar { helpMenu.AddItemAccel("User Manual", "F1", func() { native.OpenLocalURL(balance.GuidebookPath) }) + helpMenu.AddItem("Register", func() { + if u.licenseWindow == nil { + cfg := windows.License{ + Supervisor: u.Supervisor, + Engine: d.Engine, + OnCancel: func() { + u.licenseWindow.Hide() + }, + } + cfg.OnLicensed = func() { + // License status has changed, reload the window! + if u.licenseWindow != nil { + u.licenseWindow.Hide() + } + u.licenseWindow = windows.MakeLicenseWindow(d.width, d.height, cfg) + } + + cfg.OnLicensed() + } + u.licenseWindow.Show() + }) helpMenu.AddItem("About", func() { if u.aboutWindow == nil { u.aboutWindow = windows.NewAboutWindow(windows.About{ @@ -747,7 +768,7 @@ func (u *EditorUI) SetupStatusBar(d *Doodle) *ui.Frame { } var shareware string - if balance.FreeVersion { + if !license.IsRegistered() { shareware = " (shareware)" } extraLabel := ui.NewLabel(ui.Label{ diff --git a/pkg/license/admin.go b/pkg/license/admin.go new file mode 100644 index 0000000..af0ed06 --- /dev/null +++ b/pkg/license/admin.go @@ -0,0 +1,100 @@ +package license + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "io/ioutil" + "time" + + "github.com/dgrijalva/jwt-go" +) + +// AdminGenerateKeys generates the ECDSA public and private key pair for the admin +// side of creating signed license files. +func AdminGenerateKeys() (*ecdsa.PrivateKey, error) { + privateKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + return privateKey, err +} + +// AdminWriteKeys writes the admin signing key to .pem files on disk. +func AdminWriteKeys(key *ecdsa.PrivateKey, privateFile, publicFile string) error { + // Encode the private key to PEM format. + x509Encoded, err := x509.MarshalECPrivateKey(key) + if err != nil { + return err + } + pemEncoded := pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: x509Encoded, + }) + + // Encode the public key to PEM format. + x509EncodedPub, err := x509.MarshalPKIXPublicKey(key.Public()) + if err != nil { + return err + } + pemEncodedPub := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: x509EncodedPub, + }) + + // Write the files. + if err := ioutil.WriteFile(privateFile, pemEncoded, 0600); err != nil { + return err + } + if err := ioutil.WriteFile(publicFile, pemEncodedPub, 0644); err != nil { + return err + } + + return nil +} + +// AdminLoadPrivateKey loads the private key from disk. +func AdminLoadPrivateKey(privateFile string) (*ecdsa.PrivateKey, error) { + // Read the private key file. + pemEncoded, err := ioutil.ReadFile(privateFile) + if err != nil { + return nil, err + } + + // Decode the private key. + block, _ := pem.Decode([]byte(pemEncoded)) + x509Encoded := block.Bytes + privateKey, _ := x509.ParseECPrivateKey(x509Encoded) + return privateKey, nil +} + +// AdminLoadPublicKey loads the private key from disk. +func AdminLoadPublicKey(publicFile string) (*ecdsa.PublicKey, error) { + pemEncodedPub, err := ioutil.ReadFile(publicFile) + if err != nil { + return nil, err + } + + // Decode the public key. + blockPub, _ := pem.Decode([]byte(pemEncodedPub)) + x509EncodedPub := blockPub.Bytes + genericPublicKey, _ := x509.ParsePKIXPublicKey(x509EncodedPub) + publicKey := genericPublicKey.(*ecdsa.PublicKey) + + return publicKey, nil +} + +// AdminSignRegistration signs the registration object. +func AdminSignRegistration(key *ecdsa.PrivateKey, reg Registration) (string, error) { + reg.StandardClaims = jwt.StandardClaims{ + Issuer: "Maze Admin", + IssuedAt: time.Now().Unix(), + NotBefore: time.Now().Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodES384, reg) + signed, err := token.SignedString(key) + if err != nil { + return "", err + } + return signed, nil +} diff --git a/pkg/license/config.go b/pkg/license/config.go new file mode 100644 index 0000000..3124b09 --- /dev/null +++ b/pkg/license/config.go @@ -0,0 +1,25 @@ +package license + +import ( + "crypto/ecdsa" + "fmt" +) + +// Run-time configuration variables provided by the application. +const pemSigningKey = `-----BEGIN PUBLIC KEY----- +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEMrqAMHjZ1dPlKwDOsiCSr5N3OSvnYKLM +efe2xD+5hJYrpvparRFnaMbMuqde4M6d6sCCKO8BHtfAzmyiQ/CD38zs9MiDsamy +FDYEEJu+Fqx482I7fIa5ZEE770+wWJ3k +-----END PUBLIC KEY-----` + +var Signer *ecdsa.PublicKey + +func init() { + key, err := ParsePublicKeyPEM(pemSigningKey) + if err != nil { + fmt.Printf("license: failed to parse app keys: %s\n", err) + return + } + + Signer = key +} diff --git a/pkg/license/license.go b/pkg/license/license.go new file mode 100644 index 0000000..fc7a106 --- /dev/null +++ b/pkg/license/license.go @@ -0,0 +1,103 @@ +// Package license holds functions related to paid product activation. +package license + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "errors" + "io/ioutil" + "path/filepath" + + "git.kirsle.net/apps/doodle/pkg/userdir" + "github.com/dgrijalva/jwt-go" +) + +// Registration object encoded into a license key file. +type Registration struct { + Name string `json:"name"` + Email string `json:"email"` + jwt.StandardClaims +} + +// IsRegistered returns a boolean answer: is the product registered? +func IsRegistered() bool { + if _, err := GetRegistration(); err == nil { + return true + } + return false +} + +// GetRegistration returns the currently registered user, by checking +// for the license.key file in the profile folder. +func GetRegistration() (Registration, error) { + if Signer == nil { + return Registration{}, errors.New("signer not ready") + } + + filename := filepath.Join(userdir.ProfileDirectory, "license.key") + jwt, err := ioutil.ReadFile(filename) + if err != nil { + return Registration{}, err + } + + // Check if the JWT is valid. + reg, err := Validate(Signer, string(jwt)) + if err != nil { + return Registration{}, err + } + + return reg, err +} + +// UploadLicenseFile handles the user selecting the license key file, and it is +// validated and ingested. +func UploadLicenseFile(filename string) (Registration, error) { + if Signer == nil { + return Registration{}, errors.New("signer not ready") + } + + jwt, err := ioutil.ReadFile(filename) + if err != nil { + return Registration{}, err + } + + // Check if the JWT is valid. + reg, err := Validate(Signer, string(jwt)) + if err != nil { + return Registration{}, err + } + + // Upload the license to Doodle's profile directory. + outfile := filepath.Join(userdir.ProfileDirectory, "license.key") + if err := ioutil.WriteFile(outfile, jwt, 0644); err != nil { + return Registration{}, err + } + + return reg, nil +} + +// Validate the registration is signed by the appropriate public key. +func Validate(publicKey *ecdsa.PublicKey, tokenString string) (Registration, error) { + var reg Registration + token, err := jwt.ParseWithClaims(tokenString, ®, func(token *jwt.Token) (interface{}, error) { + return publicKey, nil + }) + if err != nil { + return reg, err + } + + if !token.Valid { + return reg, errors.New("token not valid") + } + return reg, nil +} + +// ParsePublicKeyPEM loads a public key from PEM format. +func ParsePublicKeyPEM(keytext string) (*ecdsa.PublicKey, error) { + blockPub, _ := pem.Decode([]byte(keytext)) + x509EncodedPub := blockPub.Bytes + genericPublicKey, _ := x509.ParsePKIXPublicKey(x509EncodedPub) + publicKey := genericPublicKey.(*ecdsa.PublicKey) + return publicKey, nil +} diff --git a/pkg/main_scene.go b/pkg/main_scene.go index 4644ac8..5cd1d9e 100644 --- a/pkg/main_scene.go +++ b/pkg/main_scene.go @@ -6,12 +6,14 @@ import ( "git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/branding" "git.kirsle.net/apps/doodle/pkg/level" + "git.kirsle.net/apps/doodle/pkg/license" "git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/native" "git.kirsle.net/apps/doodle/pkg/scripting" "git.kirsle.net/apps/doodle/pkg/shmem" "git.kirsle.net/apps/doodle/pkg/uix" "git.kirsle.net/apps/doodle/pkg/updater" + "git.kirsle.net/apps/doodle/pkg/windows" "git.kirsle.net/go/render" "git.kirsle.net/go/render/event" "git.kirsle.net/go/ui" @@ -30,6 +32,8 @@ type MainScene struct { labelVersion *ui.Label labelHint *ui.Label frame *ui.Frame // Main button frame + btnRegister *ui.Button + winRegister *ui.Window // Update check variables. updateButton *ui.Button @@ -63,7 +67,7 @@ func (s *MainScene) Setup(d *Doodle) error { // Version label. var shareware string - if balance.FreeVersion { + if !license.IsRegistered() { shareware = " (shareware)" } ver := ui.NewLabel(ui.Label{ @@ -106,6 +110,41 @@ func (s *MainScene) Setup(d *Doodle) error { s.updateButton.Hide() s.Supervisor.Add(s.updateButton) + // Register button. + s.btnRegister = ui.NewButton("Register", ui.NewLabel(ui.Label{ + Text: "Register Game", + Font: balance.LabelFont, + })) + s.btnRegister.SetStyle(&balance.ButtonPrimary) + s.btnRegister.Handle(ui.Click, func(ed ui.EventData) error { + if s.winRegister == nil { + cfg := windows.License{ + Supervisor: s.Supervisor, + Engine: d.Engine, + OnCancel: func() { + s.winRegister.Hide() + }, + } + cfg.OnLicensed = func() { + // License status has changed, reload the window! + if s.winRegister != nil { + s.winRegister.Hide() + } + s.winRegister = windows.MakeLicenseWindow(d.width, d.height, cfg) + } + + cfg.OnLicensed() + } + s.winRegister.Show() + return nil + }) + s.btnRegister.Compute(d.Engine) + s.Supervisor.Add(s.btnRegister) + + if license.IsRegistered() { + s.btnRegister.Hide() + } + // Main UI button frame. frame := ui.NewFrame("frame") s.frame = frame @@ -329,7 +368,7 @@ func (s *MainScene) Draw(d *Doodle) error { // Hint label. s.labelHint.MoveTo(render.Point{ - X: d.width - s.labelHint.Size().W - 32, + X: (d.width / 2) - (s.labelHint.Size().W / 2), Y: d.height - s.labelHint.Size().H - 32, }) s.labelHint.Present(d.Engine, s.labelHint.Point()) @@ -348,6 +387,16 @@ func (s *MainScene) Draw(d *Doodle) error { }) s.frame.Present(d.Engine, s.frame.Point()) + // Register button. + s.btnRegister.MoveTo(render.Point{ + X: d.width - s.btnRegister.Size().W - 24, + Y: d.height - s.btnRegister.Size().H - 24, + }) + s.btnRegister.Present(d.Engine, s.btnRegister.Point()) + + // Present supervised windows. + s.Supervisor.Present(d.Engine) + return nil } diff --git a/pkg/windows/license_key.go b/pkg/windows/license_key.go new file mode 100644 index 0000000..a31c18c --- /dev/null +++ b/pkg/windows/license_key.go @@ -0,0 +1,275 @@ +package windows + +import ( + "fmt" + "time" + + "git.kirsle.net/apps/doodle/pkg/balance" + "git.kirsle.net/apps/doodle/pkg/branding" + "git.kirsle.net/apps/doodle/pkg/license" + "git.kirsle.net/apps/doodle/pkg/modal" + "git.kirsle.net/apps/doodle/pkg/native" + "git.kirsle.net/go/render" + "git.kirsle.net/go/ui" + "git.kirsle.net/go/ui/style" +) + +// License window. +type License struct { + // Settings passed in by doodle + Supervisor *ui.Supervisor + Engine render.Engine + + OnLicensed func() + OnCancel func() +} + +// MakeLicenseWindow initializes a license window for any scene. +// The window width/height are the actual SDL2 window dimensions. +func MakeLicenseWindow(windowWidth, windowHeight int, cfg License) *ui.Window { + win := NewLicenseWindow(cfg) + win.Compute(cfg.Engine) + win.Supervise(cfg.Supervisor) + + // Center the window. + size := win.Size() + win.MoveTo(render.Point{ + X: (windowWidth / 2) - (size.W / 2), + Y: (windowHeight / 2) - (size.H / 2), + }) + + return win +} + +// NewLicenseWindow initializes the window. +func NewLicenseWindow(cfg License) *ui.Window { + var ( + windowWidth = 340 + windowHeight = 320 + labelSize = render.NewRect(100, 16) + valueSize = render.NewRect(windowWidth-labelSize.W-4, labelSize.H) + isRegistered bool + registration license.Registration + summary = "Unregistered (shareware)" + ) + + // Get our current registration status. + if reg, err := license.GetRegistration(); err == nil { + isRegistered = true + registration = reg + windowHeight = 200 + summary = "Registered" + } + + window := ui.NewWindow("Registration") + window.SetButtons(ui.CloseButton) + window.Configure(ui.Config{ + Width: windowWidth, + Height: windowHeight, + Background: render.RGBA(200, 200, 255, 255), + }) + + var rows = []struct { + IfRegistered bool + IfUnregistered bool + + Label string + Text string + Button *ui.Button + ButtonStyle *style.Button + Func func() + PadY int + PadX int + }{ + { + Label: "Version:", + Text: fmt.Sprintf("%s v%s", branding.AppName, branding.Version), + }, + { + Label: "Status:", + Text: summary, + }, + { + IfRegistered: true, + Label: "Name:", + Text: registration.Name, + }, + { + IfRegistered: true, + Label: "Email:", + Text: registration.Email, + }, + { + IfRegistered: true, + Label: "Issued:", + Text: time.Unix(registration.IssuedAt, 0).Format("Jan 2, 2006 15:04:05 MST"), + }, + { + IfUnregistered: true, + Text: "Register your game today! By purchasing the full\n" + + "version of Sketchy Maze, you will unlock additional\n" + + "features including improved support for custom\n" + + "doodads that attach with your level files for easy\n" + + "sharing between multiple computers.\n\n" + + "When you purchase the game you will receive a\n" + + "license key file; click the button below to browse\n" + + "and select the key file to register this copy of\n" + + branding.AppName + ".", + PadY: 8, + PadX: 2, + }, + { + IfRegistered: true, + Text: "Thank you for your support!", + PadY: 8, + PadX: 2, + }, + { + IfUnregistered: true, + Button: ui.NewButton("Key Browse", ui.NewLabel(ui.Label{ + Text: "Upload License Key File", + Font: balance.UIFont, + })), + ButtonStyle: &balance.ButtonPrimary, + Func: func() { + filename, err := native.OpenFile("Select License File", "*.key *.txt") + if err != nil { + modal.Alert(err.Error()) + return + } + + // Upload and validate the license key. + reg, err := license.UploadLicenseFile(filename) + if err != nil { + modal.Alert(err.Error()) + return + } + + modal.Alert("Thank you, %s!", reg.Name).WithTitle("Registration OK!") + if cfg.OnLicensed != nil { + cfg.OnLicensed() + } + }, + }, + } + for _, row := range rows { + row := row + + // It has a conditional? + if (row.IfRegistered && !isRegistered) || + (row.IfUnregistered && isRegistered) { + continue + } + + frame := ui.NewFrame("Frame") + if row.Label != "" { + lf := ui.NewFrame("LabelFrame") + lf.Resize(labelSize) + label := ui.NewLabel(ui.Label{ + Text: row.Label, + Font: balance.LabelFont, + }) + lf.Pack(label, ui.Pack{ + Side: ui.E, + }) + frame.Pack(lf, ui.Pack{ + Side: ui.W, + }) + } + + if row.Text != "" { + tf := ui.NewFrame("TextFrame") + if row.Label != "" { + tf.Resize(valueSize) + } + label := ui.NewLabel(ui.Label{ + Text: row.Text, + Font: balance.UIFont, + }) + tf.Pack(label, ui.Pack{ + Side: ui.W, + }) + frame.Pack(tf, ui.Pack{ + Side: ui.W, + }) + } + + if row.Button != nil { + btn := row.Button + if row.ButtonStyle != nil { + btn.SetStyle(row.ButtonStyle) + } + btn.Handle(ui.Click, func(ed ui.EventData) error { + if row.Func != nil { + row.Func() + } + return nil + }) + btn.Compute(cfg.Engine) + cfg.Supervisor.Add(btn) + frame.Pack(btn, ui.Pack{ + Side: ui.N, + }) + } + window.Pack(frame, ui.Pack{ + Side: ui.N, + FillX: true, + PadY: row.PadY, + PadX: row.PadX, + }) + } + + ///////////// + // Buttons at bottom of window + + bottomFrame := ui.NewFrame("Button Frame") + window.Pack(bottomFrame, ui.Pack{ + Side: ui.N, + FillX: true, + }) + + frame := ui.NewFrame("Button frame") + buttons := []struct { + label string + f func() + }{ + {"Website", func() { + native.OpenURL(branding.Website) + }}, + {"Close", func() { + if cfg.OnCancel != nil { + cfg.OnCancel() + } + }}, + } + for _, button := range buttons { + button := button + + btn := ui.NewButton(button.label, ui.NewLabel(ui.Label{ + Text: button.label, + Font: balance.MenuFont, + })) + + btn.Handle(ui.Click, func(ed ui.EventData) error { + button.f() + return nil + }) + + btn.Compute(cfg.Engine) + cfg.Supervisor.Add(btn) + + frame.Pack(btn, ui.Pack{ + Side: ui.W, + PadX: 4, + Expand: true, + Fill: true, + }) + } + bottomFrame.Pack(frame, ui.Pack{ + Side: ui.N, + PadX: 8, + PadY: 12, + }) + + return window +}