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.
This commit is contained in:
parent
d6f86487f5
commit
0449737607
|
@ -9,8 +9,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.kirsle.net/apps/doodle/cmd/doodad/commands"
|
"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/branding"
|
||||||
|
"git.kirsle.net/apps/doodle/pkg/license"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -32,7 +32,7 @@ func main() {
|
||||||
app.Usage = "command line interface for Doodle"
|
app.Usage = "command line interface for Doodle"
|
||||||
|
|
||||||
var freeLabel string
|
var freeLabel string
|
||||||
if balance.FreeVersion {
|
if !license.IsRegistered() {
|
||||||
freeLabel = " (shareware)"
|
freeLabel = " (shareware)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
44
cmd/doodle-admin/command/key.go
Normal file
44
cmd/doodle-admin/command/key.go
Normal file
|
@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
73
cmd/doodle-admin/command/sign.go
Normal file
73
cmd/doodle-admin/command/sign.go
Normal file
|
@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
59
cmd/doodle-admin/command/verify.go
Normal file
59
cmd/doodle-admin/command/verify.go
Normal file
|
@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
59
cmd/doodle-admin/main.go
Normal file
59
cmd/doodle-admin/main.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"git.kirsle.net/apps/doodle/pkg/balance"
|
"git.kirsle.net/apps/doodle/pkg/balance"
|
||||||
"git.kirsle.net/apps/doodle/pkg/bindata"
|
"git.kirsle.net/apps/doodle/pkg/bindata"
|
||||||
"git.kirsle.net/apps/doodle/pkg/branding"
|
"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/log"
|
||||||
"git.kirsle.net/apps/doodle/pkg/shmem"
|
"git.kirsle.net/apps/doodle/pkg/shmem"
|
||||||
"git.kirsle.net/apps/doodle/pkg/sound"
|
"git.kirsle.net/apps/doodle/pkg/sound"
|
||||||
|
@ -51,7 +52,7 @@ func main() {
|
||||||
app.Usage = fmt.Sprintf("%s - %s", branding.AppName, branding.Summary)
|
app.Usage = fmt.Sprintf("%s - %s", branding.AppName, branding.Summary)
|
||||||
|
|
||||||
var freeLabel string
|
var freeLabel string
|
||||||
if balance.FreeVersion {
|
if !license.IsRegistered() {
|
||||||
freeLabel = " (shareware)"
|
freeLabel = " (shareware)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -127,7 +127,9 @@ func init() {
|
||||||
ButtonDanger.HoverForeground = ButtonPrimary.Foreground
|
ButtonDanger.HoverForeground = ButtonPrimary.Foreground
|
||||||
|
|
||||||
ButtonBabyBlue.Background = render.RGBA(0, 153, 255, 255)
|
ButtonBabyBlue.Background = render.RGBA(0, 153, 255, 255)
|
||||||
|
ButtonBabyBlue.Foreground = render.White
|
||||||
ButtonBabyBlue.HoverBackground = render.RGBA(0, 220, 255, 255)
|
ButtonBabyBlue.HoverBackground = render.RGBA(0, 220, 255, 255)
|
||||||
|
ButtonBabyBlue.HoverForeground = render.White
|
||||||
|
|
||||||
ButtonPink.Background = render.RGBA(255, 153, 255, 255)
|
ButtonPink.Background = render.RGBA(255, 153, 255, 255)
|
||||||
ButtonPink.HoverBackground = render.RGBA(255, 220, 255, 255)
|
ButtonPink.HoverBackground = render.RGBA(255, 220, 255, 255)
|
||||||
|
|
|
@ -4,7 +4,7 @@ package branding
|
||||||
const (
|
const (
|
||||||
AppName = "Sketchy Maze"
|
AppName = "Sketchy Maze"
|
||||||
Summary = "A drawing-based maze game"
|
Summary = "A drawing-based maze game"
|
||||||
Version = "0.6.1-alpha"
|
Version = "0.6.1"
|
||||||
Website = "https://www.sketchymaze.com"
|
Website = "https://www.sketchymaze.com"
|
||||||
Copyright = "2021 Noah Petherbridge"
|
Copyright = "2021 Noah Petherbridge"
|
||||||
|
|
||||||
|
|
|
@ -233,11 +233,6 @@ func (d *Doodle) NewMap() {
|
||||||
|
|
||||||
// NewDoodad loads a new Doodad in Edit Mode.
|
// NewDoodad loads a new Doodad in Edit Mode.
|
||||||
func (d *Doodle) NewDoodad(size int) {
|
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")
|
log.Info("Starting a new doodad")
|
||||||
scene := &EditorScene{
|
scene := &EditorScene{
|
||||||
DrawingType: enum.DoodadDrawing,
|
DrawingType: enum.DoodadDrawing,
|
||||||
|
@ -262,9 +257,6 @@ func (d *Doodle) EditDrawing(filename string) error {
|
||||||
log.Info("is a Level type")
|
log.Info("is a Level type")
|
||||||
scene.DrawingType = enum.LevelDrawing
|
scene.DrawingType = enum.LevelDrawing
|
||||||
case ".doodad":
|
case ".doodad":
|
||||||
if balance.FreeVersion {
|
|
||||||
return fmt.Errorf("Doodad editor not supported in your version of the game")
|
|
||||||
}
|
|
||||||
scene.DrawingType = enum.DoodadDrawing
|
scene.DrawingType = enum.DoodadDrawing
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("file extension '%s' doesn't indicate its drawing type", ext)
|
return fmt.Errorf("file extension '%s' doesn't indicate its drawing type", ext)
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"git.kirsle.net/apps/doodle/pkg/drawtool"
|
"git.kirsle.net/apps/doodle/pkg/drawtool"
|
||||||
"git.kirsle.net/apps/doodle/pkg/enum"
|
"git.kirsle.net/apps/doodle/pkg/enum"
|
||||||
"git.kirsle.net/apps/doodle/pkg/level"
|
"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/log"
|
||||||
"git.kirsle.net/apps/doodle/pkg/native"
|
"git.kirsle.net/apps/doodle/pkg/native"
|
||||||
"git.kirsle.net/apps/doodle/pkg/uix"
|
"git.kirsle.net/apps/doodle/pkg/uix"
|
||||||
|
@ -51,6 +52,7 @@ type EditorUI struct {
|
||||||
layersWindow *ui.Window
|
layersWindow *ui.Window
|
||||||
publishWindow *ui.Window
|
publishWindow *ui.Window
|
||||||
filesystemWindow *ui.Window
|
filesystemWindow *ui.Window
|
||||||
|
licenseWindow *ui.Window
|
||||||
|
|
||||||
// Palette window.
|
// Palette window.
|
||||||
Palette *ui.Window
|
Palette *ui.Window
|
||||||
|
@ -508,7 +510,6 @@ func (u *EditorUI) SetupMenuBar(d *Doodle) *ui.MenuBar {
|
||||||
d.GotoNewMenu()
|
d.GotoNewMenu()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if !balance.FreeVersion {
|
|
||||||
fileMenu.AddItem("New doodad", func() {
|
fileMenu.AddItem("New doodad", func() {
|
||||||
u.Scene.ConfirmUnload(func() {
|
u.Scene.ConfirmUnload(func() {
|
||||||
d.Prompt("Doodad size [100]>", func(answer string) {
|
d.Prompt("Doodad size [100]>", func(answer string) {
|
||||||
|
@ -525,7 +526,6 @@ func (u *EditorUI) SetupMenuBar(d *Doodle) *ui.MenuBar {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
|
||||||
fileMenu.AddItemAccel("Save", "Ctrl-S*", func() {
|
fileMenu.AddItemAccel("Save", "Ctrl-S*", func() {
|
||||||
if u.Scene.filename != "" {
|
if u.Scene.filename != "" {
|
||||||
saveFunc(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() {
|
helpMenu.AddItemAccel("User Manual", "F1", func() {
|
||||||
native.OpenLocalURL(balance.GuidebookPath)
|
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() {
|
helpMenu.AddItem("About", func() {
|
||||||
if u.aboutWindow == nil {
|
if u.aboutWindow == nil {
|
||||||
u.aboutWindow = windows.NewAboutWindow(windows.About{
|
u.aboutWindow = windows.NewAboutWindow(windows.About{
|
||||||
|
@ -747,7 +768,7 @@ func (u *EditorUI) SetupStatusBar(d *Doodle) *ui.Frame {
|
||||||
}
|
}
|
||||||
|
|
||||||
var shareware string
|
var shareware string
|
||||||
if balance.FreeVersion {
|
if !license.IsRegistered() {
|
||||||
shareware = " (shareware)"
|
shareware = " (shareware)"
|
||||||
}
|
}
|
||||||
extraLabel := ui.NewLabel(ui.Label{
|
extraLabel := ui.NewLabel(ui.Label{
|
||||||
|
|
100
pkg/license/admin.go
Normal file
100
pkg/license/admin.go
Normal file
|
@ -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
|
||||||
|
}
|
25
pkg/license/config.go
Normal file
25
pkg/license/config.go
Normal file
|
@ -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
|
||||||
|
}
|
103
pkg/license/license.go
Normal file
103
pkg/license/license.go
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -6,12 +6,14 @@ import (
|
||||||
"git.kirsle.net/apps/doodle/pkg/balance"
|
"git.kirsle.net/apps/doodle/pkg/balance"
|
||||||
"git.kirsle.net/apps/doodle/pkg/branding"
|
"git.kirsle.net/apps/doodle/pkg/branding"
|
||||||
"git.kirsle.net/apps/doodle/pkg/level"
|
"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/log"
|
||||||
"git.kirsle.net/apps/doodle/pkg/native"
|
"git.kirsle.net/apps/doodle/pkg/native"
|
||||||
"git.kirsle.net/apps/doodle/pkg/scripting"
|
"git.kirsle.net/apps/doodle/pkg/scripting"
|
||||||
"git.kirsle.net/apps/doodle/pkg/shmem"
|
"git.kirsle.net/apps/doodle/pkg/shmem"
|
||||||
"git.kirsle.net/apps/doodle/pkg/uix"
|
"git.kirsle.net/apps/doodle/pkg/uix"
|
||||||
"git.kirsle.net/apps/doodle/pkg/updater"
|
"git.kirsle.net/apps/doodle/pkg/updater"
|
||||||
|
"git.kirsle.net/apps/doodle/pkg/windows"
|
||||||
"git.kirsle.net/go/render"
|
"git.kirsle.net/go/render"
|
||||||
"git.kirsle.net/go/render/event"
|
"git.kirsle.net/go/render/event"
|
||||||
"git.kirsle.net/go/ui"
|
"git.kirsle.net/go/ui"
|
||||||
|
@ -30,6 +32,8 @@ type MainScene struct {
|
||||||
labelVersion *ui.Label
|
labelVersion *ui.Label
|
||||||
labelHint *ui.Label
|
labelHint *ui.Label
|
||||||
frame *ui.Frame // Main button frame
|
frame *ui.Frame // Main button frame
|
||||||
|
btnRegister *ui.Button
|
||||||
|
winRegister *ui.Window
|
||||||
|
|
||||||
// Update check variables.
|
// Update check variables.
|
||||||
updateButton *ui.Button
|
updateButton *ui.Button
|
||||||
|
@ -63,7 +67,7 @@ func (s *MainScene) Setup(d *Doodle) error {
|
||||||
|
|
||||||
// Version label.
|
// Version label.
|
||||||
var shareware string
|
var shareware string
|
||||||
if balance.FreeVersion {
|
if !license.IsRegistered() {
|
||||||
shareware = " (shareware)"
|
shareware = " (shareware)"
|
||||||
}
|
}
|
||||||
ver := ui.NewLabel(ui.Label{
|
ver := ui.NewLabel(ui.Label{
|
||||||
|
@ -106,6 +110,41 @@ func (s *MainScene) Setup(d *Doodle) error {
|
||||||
s.updateButton.Hide()
|
s.updateButton.Hide()
|
||||||
s.Supervisor.Add(s.updateButton)
|
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.
|
// Main UI button frame.
|
||||||
frame := ui.NewFrame("frame")
|
frame := ui.NewFrame("frame")
|
||||||
s.frame = frame
|
s.frame = frame
|
||||||
|
@ -329,7 +368,7 @@ func (s *MainScene) Draw(d *Doodle) error {
|
||||||
|
|
||||||
// Hint label.
|
// Hint label.
|
||||||
s.labelHint.MoveTo(render.Point{
|
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,
|
Y: d.height - s.labelHint.Size().H - 32,
|
||||||
})
|
})
|
||||||
s.labelHint.Present(d.Engine, s.labelHint.Point())
|
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())
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
275
pkg/windows/license_key.go
Normal file
275
pkg/windows/license_key.go
Normal file
|
@ -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
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user