lbc_launcher/main.go
2025-09-16 12:13:15 +03:00

548 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"encoding/json"
"errors"
"fmt"
_ "image/png"
"io"
"os"
"path/filepath"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/widget"
_ "embed"
"github.com/emersion/go-autostart"
"github.com/gofrs/flock"
"github.com/gregorybednov/lbc/cli"
toml "github.com/pelletier/go-toml/v2"
)
//go:embed icon.png
var iconPNG []byte
func appIcon() fyne.Resource {
return fyne.NewStaticResource("icon.png", iconPNG)
}
/* --------------------------- Конфиг лончера --------------------------- */
type LauncherCfg struct {
FirstRun bool `json:"firstRun"`
StartMinimized bool `json:"startMinimized"`
AutoStart bool `json:"autoStart"`
NodeHome string `json:"nodeHome"` // каталог узла (HOME)
LastStarted string `json:"lastStarted"`
}
type AppState struct {
cfgPath string
cfg LauncherCfg
lockF *flock.Flock
// UI
app fyne.App
win fyne.Window
statusLbl *widget.Label
logArea *widget.Entry
}
/* --------------------------- TOML-модель (минимум) --------------------------- */
type ConfigTOML struct {
Abci string `toml:"abci"`
DBBackend string `toml:"db_backend"`
DBDir string `toml:"db_dir"`
FilterPeers bool `toml:"filter_peers"`
GenesisFile string `toml:"genesis_file"`
LogFormat string `toml:"log_format"`
LogLevel string `toml:"log_level"`
Moniker string `toml:"moniker"`
NodeKeyFile string `toml:"node_key_file"`
P2P struct {
AddrBookFile string `toml:"addr_book_file"`
AddrBookStrict bool `toml:"addr_book_strict"`
AllowDuplicateIP bool `toml:"allow_duplicate_ip"`
BootstrapPeers string `toml:"bootstrap_peers"`
ExternalAddress string `toml:"external_address"`
Laddr string `toml:"laddr"`
PersistentPeers string `toml:"persistent_peers"`
QueueType string `toml:"queue_type"`
UPnP bool `toml:"upnp"`
UseLegacy bool `toml:"use_legacy"`
} `toml:"p2p"`
PrivValidator struct {
KeyFile string `toml:"key_file"`
StateFile string `toml:"state_file"`
} `toml:"priv_validator"`
Ygg struct {
AdminListen string `toml:"admin_listen"`
AllowedPubKeys []string `toml:"allowed_public_keys"`
Peers string `toml:"peers"`
PrivateKeyFile string `toml:"private_key_file"`
} `toml:"yggdrasil"`
}
type uiWriter struct{ st *AppState }
func (w *uiWriter) Write(p []byte) (int, error) {
w.st.appendLog(string(p))
return len(p), nil
}
/* -------------------------------- main -------------------------------- */
func main() {
st := &AppState{}
must(initConfig(st))
must(acquireSingleInstanceLock(st))
defer releaseSingleInstanceLock(st)
exe, _ := os.Executable()
exe, _ = filepath.EvalSymlinks(exe)
autoApp := &autostart.App{
Name: "LBClient",
DisplayName: "LBClient",
Exec: []string{exe},
}
a := app.New()
w := a.NewWindow("LBС Launcher")
st.app, st.win = a, w
// Статус + логи
st.statusLbl = widget.NewLabel("Готов")
st.logArea = widget.NewMultiLineEntry()
st.logArea.SetPlaceHolder("Здесь будет вывод команд lbc…")
st.logArea.Disable() // только для чтения
homeEntry := widget.NewEntry()
homeEntry.SetPlaceHolder("Каталог HOME узла (будут data/, config/)")
homeEntry.SetText(st.cfg.NodeHome)
btnPickHome := widget.NewButton("Выбрать HOME…", func() {
dd := dialog.NewFolderOpen(func(lu fyne.ListableURI, err error) {
if lu == nil || err != nil {
return
}
st.cfg.NodeHome = lu.Path()
homeEntry.SetText(st.cfg.NodeHome)
_ = saveConfig(st)
}, w)
dd.Show()
})
topControls := container.NewVBox(
widget.NewLabelWithStyle("HOME:", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
homeEntry, btnPickHome,
)
// Вкладка: Init genesis
initGenesisBtn := widget.NewButton("Инициализировать новую сеть (init genesis)", func() {
if !st.ensurePathsOk(homeEntry.Text) {
return
}
go func() {
w := &uiWriter{st}
if err := cli.ExecuteWithArgs([]string{"init", "genesis"}, homeEntry.Text, w, w); err != nil {
st.alert("Ошибка: " + err.Error())
} else {
st.notify("init genesis: готово")
}
}()
})
tabInitGenesis := container.NewBorder(nil, nil, nil, nil,
container.NewVBox(
widget.NewLabel("Создать новую блокчейн-сеть"),
widget.NewLabel("Команда: lbc init genesis"),
initGenesisBtn,
),
)
// Вкладка: Export genesis.json
exportBtn := widget.NewButton("Экспортировать genesis.json…", func() {
// genesis лежит в HOME/config/genesis.json
if homeEntry.Text == "" {
st.alert("Сначала укажи HOME каталóg узла.")
return
}
src := filepath.Join(homeEntry.Text, "config", "genesis.json")
if _, err := os.Stat(src); err != nil {
st.alert("Не найден файл: " + src)
return
}
fd := dialog.NewFileSave(func(uw fyne.URIWriteCloser, err error) {
if uw == nil || err != nil {
return
}
defer uw.Close()
f, e := os.Open(src)
if e != nil {
st.alert("Ошибка чтения genesis: " + e.Error())
return
}
defer f.Close()
if _, e = io.Copy(uw, f); e != nil {
st.alert("Ошибка записи: " + e.Error())
return
}
st.notify("Готово: экспортирован genesis.json")
}, w)
fd.SetFileName("genesis.json")
fd.Show()
})
tabExportGenesis := container.NewVBox(
widget.NewLabel("Экспорт genesis.json из текущего узла"),
exportBtn,
)
// Вкладка: Init joiner
genesisPathEntry := widget.NewEntry()
genesisPathEntry.SetPlaceHolder("Путь к существующему genesis.json")
pickGenesis := widget.NewButton("Выбрать genesis.json…", func() {
fd := dialog.NewFileOpen(func(uc fyne.URIReadCloser, err error) {
if uc == nil || err != nil {
return
}
genesisPathEntry.SetText(uc.URI().Path())
}, w)
fd.Show()
})
joinBtn := widget.NewButton("Создать узел-присоединение (init joiner)", func() {
if !st.ensurePathsOk(homeEntry.Text) {
return
}
// 1) скопировать genesis.json в HOME/config/genesis.json
src := genesisPathEntry.Text
if src == "" {
st.alert("Укажи путь к genesis.json")
return
}
dest := filepath.Join(homeEntry.Text, "config")
_ = os.MkdirAll(dest, 0o755)
destFile := filepath.Join(dest, "genesis.json")
if err := fileCopy(src, destFile); err != nil {
st.alert("Не удалось скопировать genesis.json: " + err.Error())
return
}
// 2) lbc init joiner
go func() {
w := &uiWriter{st}
if err := cli.ExecuteWithArgs([]string{"init", "joiner", genesisPathEntry.Text}, homeEntry.Text, w, w); err != nil {
st.alert("Ошибка: " + err.Error())
} else {
st.notify("init joiner: готово")
}
}()
})
tabJoiner := container.NewVBox(
widget.NewLabel("Создать ноду в существующей сети"),
container.NewHBox(genesisPathEntry, pickGenesis),
joinBtn,
)
// Вкладка: Редактор config.toml (минималка)
// Читаем/пишем HOME/config/config.toml
cfgPathLbl := widget.NewLabel("Файл: (HOME)/config/config.toml")
monikerEntry := widget.NewEntry()
laddrEntry := widget.NewEntry()
ppeersEntry := widget.NewEntry()
logLevelSel := widget.NewSelect([]string{"debug", "info", "warn", "error"}, nil)
loadCfgBtn := widget.NewButton("Загрузить config.toml", func() {
if homeEntry.Text == "" {
st.alert("Укажи HOME узла.")
return
}
cfgp := filepath.Join(homeEntry.Text, "config", "config.toml")
if cfg, err := loadTOML(cfgp); err != nil {
st.alert("Чтение TOML: " + err.Error())
} else {
cfgPathLbl.SetText("Файл: " + cfgp)
monikerEntry.SetText(cfg.Moniker)
laddrEntry.SetText(cfg.P2P.Laddr)
ppeersEntry.SetText(cfg.P2P.PersistentPeers)
if cfg.LogLevel == "" {
cfg.LogLevel = "info"
}
logLevelSel.SetSelected(cfg.LogLevel)
st.status("config.toml загружен")
}
})
saveCfgBtn := widget.NewButton("Сохранить", func() {
if homeEntry.Text == "" {
st.alert("Укажи HOME узла.")
return
}
cfgp := filepath.Join(homeEntry.Text, "config", "config.toml")
cfg, err := loadTOML(cfgp) // мягко читаем всё, меняем только интересующие поля
if err != nil {
st.alert("Чтение TOML: " + err.Error())
return
}
cfg.Moniker = monikerEntry.Text
cfg.P2P.Laddr = laddrEntry.Text
cfg.P2P.PersistentPeers = ppeersEntry.Text
cfg.LogLevel = logLevelSel.Selected
if err := saveTOML(cfgp, cfg); err != nil {
st.alert("Запись TOML: " + err.Error())
return
}
st.notify("config.toml сохранён")
})
tabCfg := container.NewVBox(
widget.NewLabel("Редактирование основных параметров config.toml"),
cfgPathLbl,
widget.NewForm(
widget.NewFormItem("moniker", monikerEntry),
widget.NewFormItem("p2p.laddr", laddrEntry),
widget.NewFormItem("p2p.persistent_peers", ppeersEntry),
widget.NewFormItem("log_level", logLevelSel),
),
container.NewHBox(loadCfgBtn, saveCfgBtn),
)
// Вкладка: Логи
clearLog := widget.NewButton("Очистить лог", func() { st.logArea.SetText("") })
tabLogs := container.NewBorder(nil, clearLog, nil, nil, st.logArea)
// Собираем UI
tabs := container.NewAppTabs(
container.NewTabItem("Init genesis", tabInitGenesis),
container.NewTabItem("Export genesis", tabExportGenesis),
container.NewTabItem("Init joiner", tabJoiner),
container.NewTabItem("Config.toml", tabCfg),
container.NewTabItem("Logs", tabLogs),
)
tabs.SetTabLocation(container.TabLocationTop)
cbAutostart := widget.NewCheck("Запускать при входе в систему", func(v bool) {
if v {
if err := autoApp.Enable(); err != nil {
st.alert("Автозапуск: " + err.Error())
return
}
} else {
_ = autoApp.Disable()
}
st.cfg.AutoStart = v
_ = saveConfig(st)
})
cbAutostart.SetChecked(autoApp.IsEnabled())
cbStartMin := widget.NewCheck("Стартовать свернутым в трей", func(v bool) {
st.cfg.StartMinimized = v
_ = saveConfig(st)
})
cbStartMin.SetChecked(true)
top := container.NewVBox(topControls, container.NewHBox(cbAutostart, cbStartMin), st.statusLbl)
content := container.NewBorder(top, nil, nil, nil, tabs)
w.SetContent(content)
// Закрытие = скрытие (фон)
w.SetCloseIntercept(func() { w.Hide(); st.notify("Лончер работает в фоне") })
// Трей-меню
if desk, ok := a.(desktop.App); ok {
desk.SetSystemTrayIcon(appIcon())
openItem := fyne.NewMenuItem("Открыть", func() { w.Show(); w.RequestFocus() })
autoItem := fyne.NewMenuItem("Автозапуск", nil)
autoItem.Checked = autoApp.IsEnabled()
autoItem.Action = func() {
ns := !autoItem.Checked
if ns {
if err := autoApp.Enable(); err != nil {
st.alert(err.Error())
return
}
} else {
_ = autoApp.Disable()
}
autoItem.Checked = ns
st.cfg.AutoStart = ns
_ = saveConfig(st)
}
quitItem := fyne.NewMenuItem("Выйти", func() { releaseSingleInstanceLock(st); a.Quit() })
desk.SetSystemTrayMenu(fyne.NewMenu("", openItem, autoItem, fyne.NewMenuItemSeparator(), quitItem))
}
// Первый запуск / показ окна
now := time.Now().Format(time.RFC3339)
if st.cfg.FirstRun {
st.notify("Первый запуск: лончер будет работать в фоне")
st.cfg.FirstRun = false
}
st.cfg.LastStarted = now
_ = saveConfig(st)
if st.cfg.StartMinimized {
w.Hide()
} else {
w.Show()
}
a.Run()
}
/* ------------------------------ Конфиг/локи ------------------------------ */
func initConfig(st *AppState) error {
dir, err := os.UserConfigDir()
if err != nil {
return err
}
appDir := filepath.Join(dir, "lbc_launcher")
if err := os.MkdirAll(appDir, 0o755); err != nil {
return err
}
st.cfgPath = filepath.Join(appDir, "config.json")
// defaults
st.cfg = LauncherCfg{
FirstRun: true,
StartMinimized: true,
AutoStart: false,
NodeHome: "",
}
f, err := os.Open(st.cfgPath)
if errors.Is(err, os.ErrNotExist) {
return saveConfig(st)
}
if err != nil {
return err
}
defer f.Close()
data, _ := io.ReadAll(f)
_ = json.Unmarshal(data, &st.cfg)
return nil
}
func saveConfig(st *AppState) error {
tmp := st.cfgPath + ".tmp"
b, _ := json.MarshalIndent(st.cfg, "", " ")
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, st.cfgPath)
}
func acquireSingleInstanceLock(st *AppState) error {
cache, err := os.UserCacheDir()
if err != nil {
return err
}
lp := filepath.Join(cache, "lbc_launcher", "app.lock")
if err := os.MkdirAll(filepath.Dir(lp), 0o755); err != nil {
return err
}
l := flock.New(lp)
ok, err := l.TryLock()
if err != nil {
return fmt.Errorf("lock error: %w", err)
}
if !ok {
return errors.New("another instance is running")
}
st.lockF = l
return nil
}
func releaseSingleInstanceLock(st *AppState) {
if st.lockF != nil {
_ = st.lockF.Unlock()
}
}
/* ------------------------------ TOML I/O ------------------------------ */
func loadTOML(path string) (*ConfigTOML, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var cfg ConfigTOML
dec := toml.NewDecoder(f)
if err := dec.Decode(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func saveTOML(path string, cfg *ConfigTOML) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
enc := toml.NewEncoder(f)
if err := enc.Encode(cfg); err != nil {
_ = f.Close()
return err
}
_ = f.Close()
return os.Rename(tmp, path)
}
/* ------------------------------ Утилиты/UI ------------------------------ */
func (st *AppState) status(s string) {
st.statusLbl.SetText(s)
}
func (st *AppState) appendLog(s string) {
go func() {
st.win.SetContent(st.win.Content()) // гарантируем вызов из UI потока
st.logArea.SetText(st.logArea.Text + s)
st.logArea.CursorRow = 999999
}()
}
func (st *AppState) alert(msg string) {
dialog.ShowInformation("Сообщение", msg, st.win)
}
func (st *AppState) notify(msg string) {
st.app.SendNotification(&fyne.Notification{Title: "LBClient", Content: msg})
}
func (st *AppState) ensurePathsOk(home string) bool {
if home == "" {
st.alert("Укажи HOME каталог узла")
return false
}
st.cfg.NodeHome = home
_ = saveConfig(st)
return true
}
func fileCopy(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err = io.Copy(out, in); err != nil {
return err
}
return out.Close()
}
/* --------------------------------- misc -------------------------------- */
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}