Tease out fim code, further implementation of config ui (INCOMPLETE)
This commit is contained in:
parent
4a516cbed8
commit
83743a9f90
118
cmd/ui/slideshow.go
Normal file
118
cmd/ui/slideshow.go
Normal file
|
@ -0,0 +1,118 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/eiannone/keyboard"
|
||||
)
|
||||
|
||||
const (
|
||||
CMD_FIM = "/usr/local/bin/pf-fim.sh"
|
||||
SLIDESHOW_INTERVAL = 300 * time.Second
|
||||
)
|
||||
|
||||
func Slideshow() {
|
||||
// fim placeholder so we can operate on it when a exit slideshow is received
|
||||
var fim *exec.Cmd = nil
|
||||
|
||||
// Run slideshow
|
||||
fim = exec.Command(CMD_FIM)
|
||||
|
||||
// Setup stdin for fim to control slideshow
|
||||
stdin, err := fim.StdinPipe()
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting fim stdin : %s", err)
|
||||
}
|
||||
|
||||
// Advance slideshow every interval as defined in const()
|
||||
ticker := time.NewTicker(SLIDESHOW_INTERVAL)
|
||||
stop_ticker := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
_, err = io.WriteString(stdin, "n")
|
||||
if err != nil {
|
||||
log.Fatalf("Error advancing slides : %s", err)
|
||||
}
|
||||
case <-stop_ticker:
|
||||
ticker.Stop()
|
||||
stdin.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Start watching for key strokes and echo them back to stdout
|
||||
keysEvents, err := keyboard.GetKeys(10)
|
||||
if err != nil {
|
||||
log.Fatalf("Error setting up keyboard listener : %s", err)
|
||||
}
|
||||
// NOT deferring keyboard.close as we do that by hand further down when fim exits ahead of showing the config UI
|
||||
|
||||
// Goroutine for tracking which keys are pressed and controlling fim if appropriate
|
||||
keyboardCtx, keyboardCancel := context.WithCancel(context.Background())
|
||||
go func(keyboardCtx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-keyboardCtx.Done():
|
||||
return
|
||||
case event := <-keysEvents:
|
||||
if event.Err != nil {
|
||||
log.Fatalf("Error listening to key events : %s", err)
|
||||
}
|
||||
log.Printf("You pressed: key %X\n", event.Key)
|
||||
|
||||
// Keys for fim event management (previous/next in particular)
|
||||
fimKey := ""
|
||||
if event.Key == keyboard.KeyArrowLeft || event.Key == keyboard.KeyArrowDown {
|
||||
fimKey = "p"
|
||||
}
|
||||
if event.Key == keyboard.KeyArrowRight || event.Key == keyboard.KeyArrowUp {
|
||||
fimKey = "n"
|
||||
}
|
||||
|
||||
// Exit fim and move to the config UI
|
||||
if event.Key == keyboard.KeyEsc || event.Key == keyboard.KeyEnter || event.Key == keyboard.KeySpace {
|
||||
if fim != nil { // Just in case someone lays on exit key or similar during startup
|
||||
if err := fim.Process.Kill(); err != nil {
|
||||
log.Fatalf("failed to kill fim : %s", err)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Control fim if we received a valid key for next/previous slide
|
||||
if fimKey != "" {
|
||||
log.Printf("Sending key to fim : %s", fimKey)
|
||||
_, err = io.WriteString(stdin, fimKey)
|
||||
if err != nil {
|
||||
log.Fatalf("Error controlling fim : %s", err)
|
||||
}
|
||||
ticker.Reset(SLIDESHOW_INTERVAL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}(keyboardCtx)
|
||||
|
||||
// Run fim
|
||||
// if err := fim.Run(); err != nil {
|
||||
// // Unwrap the error a bit so we can find out if a signal killed fim or something else
|
||||
// // An exit code of -1 means the program didn't exit in time or was terminated by a signal (per the docs)
|
||||
// if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() != -1 {
|
||||
// log.Fatalf("Error running fim : %s", err)
|
||||
// }
|
||||
// }
|
||||
|
||||
// Stop fim slideshow advancing go routine
|
||||
close(stop_ticker)
|
||||
|
||||
// Stop listening to keyboard events
|
||||
keyboard.Close()
|
||||
keyboardCancel()
|
||||
|
||||
}
|
333
cmd/ui/ui.go
333
cmd/ui/ui.go
|
@ -2,122 +2,37 @@ package main
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/eiannone/keyboard"
|
||||
"github.com/gdamore/tcell"
|
||||
"github.com/guillermo/go.procmeminfo"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
const (
|
||||
CMD_FINDMNT = "/usr/bin/findmnt"
|
||||
CMD_FIM = "/usr/local/bin/pf-fim.sh"
|
||||
SLIDESHOW_INTERVAL = 300 * time.Second
|
||||
CMD_SYSTEMCTL = "/usr/bin/systemctl"
|
||||
CMD_FINDMNT = "/usr/bin/findmnt"
|
||||
CMD_VCGENCMD = "/opt/vc/bin/vcgencmd"
|
||||
FILE_CPU_TEMP = "/sys/class/thermal/thermal_zone0/temp"
|
||||
)
|
||||
|
||||
const (
|
||||
PAGE_MAIN_UI = "PAGE_MAIN_UI"
|
||||
PAGE_EXIT = "PAGE_EXIT"
|
||||
PAGE_REBOOT = "PAGE_REBOOT"
|
||||
PAGE_POWEROFF = "PAGE_POWEROFF"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// fim placeholder so we can operate on it when a exit slideshow is received
|
||||
var fim *exec.Cmd = nil
|
||||
|
||||
// Run slideshow
|
||||
fim = exec.Command(CMD_FIM)
|
||||
|
||||
// Setup stdin for fim to control slideshow
|
||||
stdin, err := fim.StdinPipe()
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting fim stdin : %s", err)
|
||||
}
|
||||
|
||||
// Advance slideshow every interval as defined in const()
|
||||
ticker := time.NewTicker(SLIDESHOW_INTERVAL)
|
||||
stop_ticker := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
_, err = io.WriteString(stdin, "n")
|
||||
if err != nil {
|
||||
log.Fatalf("Error advancing slides : %s", err)
|
||||
}
|
||||
case <-stop_ticker:
|
||||
ticker.Stop()
|
||||
stdin.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Start watching for key strokes and echo them back to stdout
|
||||
keysEvents, err := keyboard.GetKeys(10)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
_ = keyboard.Close()
|
||||
}()
|
||||
|
||||
// Goroutine for tracking which keys are pressed and controlling fim if appropriate
|
||||
go func() {
|
||||
for {
|
||||
event := <-keysEvents
|
||||
if event.Err != nil {
|
||||
panic(event.Err)
|
||||
}
|
||||
log.Printf("You pressed: key %X\n", event.Key)
|
||||
|
||||
// Keys for fim event management (previous/next in particular)
|
||||
fimKey := ""
|
||||
if event.Key == keyboard.KeyArrowLeft || event.Key == keyboard.KeyArrowDown {
|
||||
fimKey = "p"
|
||||
}
|
||||
if event.Key == keyboard.KeyArrowRight || event.Key == keyboard.KeyArrowUp {
|
||||
fimKey = "n"
|
||||
}
|
||||
|
||||
// Exit fim and move to the config UI
|
||||
if event.Key == keyboard.KeyEsc || event.Key == keyboard.KeyEnter || event.Key == keyboard.KeySpace {
|
||||
if fim != nil { // Just in case someone lays on exit key or similar during startup
|
||||
if err := fim.Process.Kill(); err != nil {
|
||||
log.Fatalf("failed to kill fim : %s", err)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Control fim if we received a valid key for next/previous slide
|
||||
if fimKey != "" {
|
||||
log.Printf("Sending key to fim : %s", fimKey)
|
||||
_, err = io.WriteString(stdin, fimKey)
|
||||
if err != nil {
|
||||
log.Fatalf("Error controlling fim : %s", err)
|
||||
}
|
||||
ticker.Reset(SLIDESHOW_INTERVAL)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Run fim
|
||||
// if err := fim.Run(); err != nil {
|
||||
// // Unwrap the error a bit so we can find out if a signal killed fim or something else
|
||||
// // An exit code of -1 means the program didn't exit in time or was terminated by a signal (per the docs)
|
||||
// if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() != -1 {
|
||||
// log.Fatalf("Error running fim : %s", err)
|
||||
// }
|
||||
// }
|
||||
|
||||
// Stop fim slideshow advancing go routine
|
||||
close(stop_ticker)
|
||||
|
||||
// Memory info for status panel
|
||||
meminfo := &procmeminfo.MemInfo{}
|
||||
err = meminfo.Update()
|
||||
err := meminfo.Update()
|
||||
if err != nil {
|
||||
log.Printf("Error getting memory info : %s", err)
|
||||
}
|
||||
|
@ -135,6 +50,19 @@ func main() {
|
|||
"-t", "ext4,exfat,vfat,btrfs,zfs,xfs").Output()
|
||||
filesystems := strings.Split(strings.Trim(string(findmntOut), "\n"), "\n")
|
||||
|
||||
// GPU Temp
|
||||
vcgencmdOut, _ := exec.Command(CMD_VCGENCMD, "measure_temp").Output()
|
||||
gpuTemp := strings.Split(strings.Trim(string(vcgencmdOut), "\n"), "=")[1]
|
||||
|
||||
// CPU Temp
|
||||
cpuTempFileContents, err := ioutil.ReadFile(FILE_CPU_TEMP)
|
||||
if err != nil {
|
||||
log.Fatalf("Error reading file %s : %s", FILE_CPU_TEMP, err)
|
||||
}
|
||||
cpuTempStr := strings.Trim(string(cpuTempFileContents), "\n")
|
||||
cpuTempInt, _ := strconv.Atoi(cpuTempStr)
|
||||
cpuTemp := fmt.Sprintf("%.2f'C", float64(cpuTempInt)/1000.0)
|
||||
|
||||
// Run config UI when slideshow stops
|
||||
app := tview.NewApplication()
|
||||
|
||||
|
@ -155,9 +83,7 @@ func main() {
|
|||
|
||||
// Footer fields (Left Column)
|
||||
exitButton := tview.NewButton("Exit").
|
||||
SetSelectedFunc(func() {
|
||||
app.Stop()
|
||||
})
|
||||
SetBackgroundColorActivated(tcell.ColorGray)
|
||||
exitButton.SetLabelColor(tcell.ColorBlack).
|
||||
SetBorder(true).
|
||||
SetBorderColor(tcell.ColorBlack).
|
||||
|
@ -165,9 +91,7 @@ func main() {
|
|||
SetRect(0, 0, 22, 3)
|
||||
|
||||
rebootButton := tview.NewButton("Reboot").
|
||||
SetSelectedFunc(func() {
|
||||
app.Stop()
|
||||
})
|
||||
SetBackgroundColorActivated(tcell.ColorGray)
|
||||
rebootButton.SetLabelColor(tcell.ColorBlack).
|
||||
SetBorder(true).
|
||||
SetBorderColor(tcell.ColorBlack).
|
||||
|
@ -175,9 +99,7 @@ func main() {
|
|||
SetRect(0, 0, 22, 3)
|
||||
|
||||
powerOffButton := tview.NewButton("Power Off").
|
||||
SetSelectedFunc(func() {
|
||||
app.Stop()
|
||||
})
|
||||
SetBackgroundColorActivated(tcell.ColorGray)
|
||||
powerOffButton.SetLabelColor(tcell.ColorBlack).
|
||||
SetBorder(true).
|
||||
SetBorderColor(tcell.ColorBlack).
|
||||
|
@ -189,21 +111,70 @@ func main() {
|
|||
footer.AddItem(exitButton, 0, 1, false).
|
||||
AddItem(rebootButton, 0, 1, false).
|
||||
AddItem(powerOffButton, 0, 1, false)
|
||||
|
||||
// Setup menu
|
||||
menu := tview.NewFlex().
|
||||
menu := tview.NewList()
|
||||
menu.SetBorder(true).
|
||||
SetTitle("Menu").
|
||||
SetTitleColor(tcell.ColorAqua)
|
||||
menu.AddItem("Select Albums", "", '1', nil)
|
||||
menu.AddItem("Configure WiFi", "", '2', nil)
|
||||
|
||||
// Setup base var for main column so the menu setup is easier to manage
|
||||
main := tview.NewFlex().
|
||||
SetDirection(tview.FlexRow)
|
||||
menu.SetTitle("Menu").
|
||||
|
||||
// Setup main panel (Center column)
|
||||
main.SetTitle("").
|
||||
SetBorder(true).
|
||||
SetTitleColor(tcell.ColorAqua)
|
||||
|
||||
// Setup main panel (Center column)
|
||||
main := tview.NewFlex().
|
||||
SetDirection(tview.FlexRow)
|
||||
main.SetTitle("").
|
||||
SetBorder(true)
|
||||
// WiFi Config Form
|
||||
wifiConfigForm := tview.NewForm()
|
||||
wifiConfigForm.AddInputField("Access Point", "", 0, nil, nil)
|
||||
wifiConfigForm.AddPasswordField("Password", "", 0, '*', nil)
|
||||
wifiConfigForm.AddButton("Apply", nil)
|
||||
wifiConfigForm.AddButton("Cancel", func() {
|
||||
main.Clear()
|
||||
app.SetFocus(menu)
|
||||
})
|
||||
|
||||
// Select Albums Form
|
||||
selectAlbumsForm := tview.NewForm()
|
||||
selectAlbumsForm.AddCheckbox("Album 1", false, nil)
|
||||
selectAlbumsForm.AddCheckbox("Album 2", false, nil)
|
||||
selectAlbumsForm.AddButton("Apply", nil)
|
||||
selectAlbumsForm.AddButton("Cancel", func() {
|
||||
main.Clear()
|
||||
app.SetFocus(menu)
|
||||
})
|
||||
|
||||
// Setup menu selection handler
|
||||
menu.SetSelectedFunc(func(index int, title string, desc string, shortcut rune) {
|
||||
if title == "Select Albums" {
|
||||
main.SetTitle("Select Albums")
|
||||
main.Clear()
|
||||
main.AddItem(selectAlbumsForm, 0, 1, true)
|
||||
app.SetFocus(selectAlbumsForm)
|
||||
}
|
||||
if title == "Configure WiFi" {
|
||||
main.SetTitle("Configure WiFi")
|
||||
main.Clear()
|
||||
main.AddItem(wifiConfigForm, 0, 1, true)
|
||||
app.SetFocus(wifiConfigForm)
|
||||
}
|
||||
})
|
||||
|
||||
// Side bar fields
|
||||
sideBarCPUTempTitle := tview.NewTextView().
|
||||
SetText("CPU Temperature").
|
||||
SetTextColor(tcell.ColorYellow)
|
||||
sideBarCPUTemp := tview.NewTextView().
|
||||
SetText(fmt.Sprintf(" %s", cpuTemp))
|
||||
sideBarGPUTempTitle := tview.NewTextView().
|
||||
SetText("GPU Temperature").
|
||||
SetTextColor(tcell.ColorYellow)
|
||||
sideBarGPUTemp := tview.NewTextView().
|
||||
SetText(fmt.Sprintf(" %s", gpuTemp))
|
||||
sideBarMemoryTitle := tview.NewTextView().
|
||||
SetText("Memory Use (Mb)").
|
||||
SetTextColor(tcell.ColorYellow)
|
||||
|
@ -249,6 +220,10 @@ func main() {
|
|||
sideBar.SetTitle("System Info").
|
||||
SetBorder(true).
|
||||
SetTitleColor(tcell.ColorAqua)
|
||||
sideBar.AddItem(sideBarCPUTempTitle, 1, 1, false)
|
||||
sideBar.AddItem(sideBarCPUTemp, 1, 1, false)
|
||||
sideBar.AddItem(sideBarGPUTempTitle, 1, 1, false)
|
||||
sideBar.AddItem(sideBarGPUTemp, 1, 1, false)
|
||||
sideBar.AddItem(sideBarMemoryTitle, 1, 1, false)
|
||||
sideBar.AddItem(sideBarMemoryStats, 1, 1, false)
|
||||
sideBar.AddItem(sideBarSwapTitle, 1, 1, false)
|
||||
|
@ -262,20 +237,138 @@ func main() {
|
|||
sideBar.AddItem(ipAsTextView, 1, 1, false)
|
||||
}
|
||||
|
||||
// Pages
|
||||
pages := tview.NewPages()
|
||||
|
||||
// Main UI
|
||||
grid := tview.NewGrid().
|
||||
mainUI := tview.NewGrid().
|
||||
SetRows(2, 0, 4).
|
||||
SetColumns(25, 50, 50).
|
||||
SetBorders(true).
|
||||
AddItem(header, 0, 0, 1, 3, 0, 0, false).
|
||||
AddItem(footer, 2, 0, 1, 3, 0, 0, false)
|
||||
|
||||
grid.AddItem(menu, 1, 0, 1, 1, 0, 100, true).
|
||||
mainUI.AddItem(menu, 1, 0, 1, 1, 0, 100, true).
|
||||
AddItem(main, 1, 1, 1, 1, 0, 100, false).
|
||||
AddItem(sideBar, 1, 2, 1, 1, 0, 100, false)
|
||||
|
||||
pages.AddPage(PAGE_MAIN_UI, mainUI, true, true)
|
||||
|
||||
// Button modals
|
||||
exitModal := tview.NewModal().
|
||||
SetText("Are you sure you want to [red]EXIT?").
|
||||
AddButtons([]string{"Yes", "Cancel"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "Yes" {
|
||||
app.Stop()
|
||||
}
|
||||
pages.SwitchToPage(PAGE_MAIN_UI)
|
||||
})
|
||||
pages.AddPage(PAGE_EXIT, exitModal, true, false)
|
||||
exitButton.SetSelectedFunc(func() {
|
||||
pages.ShowPage(PAGE_EXIT)
|
||||
})
|
||||
|
||||
rebootModal := tview.NewModal().
|
||||
SetText("Are you sure you want to [red]REBOOT?").
|
||||
AddButtons([]string{"Yes", "Cancel"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "Yes" {
|
||||
err := exec.Command(CMD_SYSTEMCTL, "reboot").Run()
|
||||
if err != nil {
|
||||
log.Fatalf("Could not reboot : %s ", err)
|
||||
}
|
||||
}
|
||||
pages.SwitchToPage(PAGE_MAIN_UI)
|
||||
})
|
||||
pages.AddPage(PAGE_REBOOT, rebootModal, true, false)
|
||||
rebootButton.SetSelectedFunc(func() {
|
||||
pages.ShowPage(PAGE_REBOOT)
|
||||
})
|
||||
|
||||
powerOffModal := tview.NewModal().
|
||||
SetText("Are you sure you want to [red]POWER [red]OFF?").
|
||||
AddButtons([]string{"Yes", "Cancel"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "Yes" {
|
||||
err := exec.Command(CMD_SYSTEMCTL, "poweroff").Run()
|
||||
if err != nil {
|
||||
log.Fatalf("Could not power off : %s ", err)
|
||||
}
|
||||
}
|
||||
pages.SwitchToPage(PAGE_MAIN_UI)
|
||||
})
|
||||
pages.AddPage(PAGE_POWEROFF, powerOffModal, true, false)
|
||||
powerOffButton.SetSelectedFunc(func() {
|
||||
pages.ShowPage(PAGE_POWEROFF)
|
||||
})
|
||||
|
||||
// Setup tracking of which are of the UI can/has focus
|
||||
primitivesThatCanFocus := []tview.Primitive{menu, exitButton, rebootButton, powerOffButton}
|
||||
currentFocus := 0
|
||||
|
||||
// Setup basic switching between main menu and buttons for the UI
|
||||
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
// Don't process input if we aren't on an existing element that can focus
|
||||
canContinue := false
|
||||
focusedPrimitive := app.GetFocus()
|
||||
|
||||
// Override some of the default behavior so up/dn move between fields in forms
|
||||
// Per API GetFocusedItemIndex on a form will be -1 if a form item isn't currently focused
|
||||
// We use this as a bit of a cheat to figure out if we're inside of a form that needs better nav options for users (ie. tab doesn't exist on a remote)
|
||||
wifiField, wifiButton := wifiConfigForm.GetFocusedItemIndex()
|
||||
albumField, albumButton := selectAlbumsForm.GetFocusedItemIndex()
|
||||
if wifiField != -1 || wifiButton != -1 || albumField != -1 || albumButton != -1 {
|
||||
switch event.Key() {
|
||||
case tcell.KeyUp:
|
||||
return tcell.NewEventKey(tcell.KeyBacktab, 0, event.Modifiers())
|
||||
case tcell.KeyDown:
|
||||
return tcell.NewEventKey(tcell.KeyTab, 0, event.Modifiers())
|
||||
}
|
||||
}
|
||||
|
||||
// Standard override for the screen primitives we are using (ie. don't take over control in modals)
|
||||
for _, primitive := range primitivesThatCanFocus {
|
||||
if primitive == focusedPrimitive {
|
||||
canContinue = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Bail if we shouldn't be affecting user input
|
||||
if !canContinue {
|
||||
return event
|
||||
}
|
||||
|
||||
// Add various forms of nav that make sense for a TUI (common stuff folks are used to)
|
||||
currentFocusChanged := false
|
||||
switch event.Key() {
|
||||
case tcell.KeyRight, tcell.KeyTab:
|
||||
currentFocus += 1
|
||||
if currentFocus >= len(primitivesThatCanFocus) {
|
||||
currentFocus = 0
|
||||
}
|
||||
currentFocusChanged = true
|
||||
case tcell.KeyLeft, tcell.KeyBacktab:
|
||||
currentFocus -= 1
|
||||
if currentFocus < 0 {
|
||||
currentFocus = len(primitivesThatCanFocus) - 1
|
||||
}
|
||||
currentFocusChanged = true
|
||||
}
|
||||
|
||||
// Update the focus based on navigation
|
||||
if currentFocusChanged {
|
||||
app.SetFocus(primitivesThatCanFocus[currentFocus])
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pass through event so the main UI can process stuff properly
|
||||
return event
|
||||
})
|
||||
|
||||
// Show UI and panic if there are any errors
|
||||
if err := app.SetRoot(grid, true).EnableMouse(false).Run(); err != nil {
|
||||
panic(err)
|
||||
if err := app.SetRoot(pages, true).SetFocus(primitivesThatCanFocus[currentFocus]).EnableMouse(false).Run(); err != nil {
|
||||
log.Fatalf("Failed to run UI : ", err)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue