lbc_launcher/main.go
2025-09-21 15:18:18 +03:00

554 lines
16 KiB
Go
Raw Permalink 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/driver/desktop"
"fyne.io/fyne/v2/widget"
simpledialog "github.com/sqweek/dialog"
_ "embed"
"github.com/emersion/go-autostart"
"github.com/gofrs/flock"
"github.com/gregorybednov/lbc/cli"
"github.com/pelletier/go-toml/v2"
)
//go:embed icon.png
var iconPNG []byte
/* --------------------------- Конфиг лончера --------------------------- */
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{}
err := initConfig(st)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
err = acquireSingleInstanceLock(st)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
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("Полный путь до каталога (там будут данные и настройки)")
homeEntry.SetText(st.cfg.NodeHome)
btnPickHome := widget.NewButton("Выбрать каталог…", func() {
directory, err := simpledialog.Directory().Title("Выбрать каталог с настройками и данными").Browse()
if err == nil {
st.cfg.NodeHome = directory
homeEntry.SetText(st.cfg.NodeHome)
_ = saveConfig(st)
}
})
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 {
simpledialog.Message("%s", err.Error()).Title("Произошла ошибка").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 == "" {
simpledialog.Message("%s", "Сначала укажите домашний каталог узла").Title("Не найден HOME").Error()
return
}
src := filepath.Join(homeEntry.Text, "config", "genesis.json")
if _, err := os.Stat(src); err != nil {
simpledialog.Message("%s", "Не найден файл"+src).Title("Файл не найден").Error()
return
}
savePath, err := simpledialog.File().Filter("JSON files", "json").Title("Экспорт данных о сети").Save()
if err != nil {
return
}
// открыть исходный файл
f, e := os.Open(src)
if e != nil {
simpledialog.Message("Ошибка чтения genesis: %s", e.Error()).Title("Ошибка").Error()
return
}
defer f.Close()
// создать файл для сохранения
uw, e := os.Create(savePath)
if e != nil {
simpledialog.Message("Ошибка создания файла: %s", e.Error()).Title("Ошибка").Error()
return
}
defer uw.Close()
// копируем содержимое
if _, e = io.Copy(uw, f); e != nil {
simpledialog.Message("Ошибка записи: %s", e.Error()).Title("Ошибка").Error()
return
}
st.notify(fmt.Sprintf("Готово: экспортирован genesis.json -> %s", savePath))
//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() {
filename, err := simpledialog.File().Filter("Genesis JSON file", "json").Load()
if err == nil {
genesisPathEntry.SetText(filename)
}
})
joinBtn := widget.NewButton("Создать узел-присоединение (init joiner)", func() {
if !st.ensurePathsOk(homeEntry.Text) {
return
}
// 1) скопировать genesis.json в HOME/config/genesis.json
src := genesisPathEntry.Text
if src == "" {
simpledialog.Message("%s", "Укажите путь к genesis.json").Title("Не найден файл").Error()
return
}
dest := filepath.Join(homeEntry.Text, "config")
_ = os.MkdirAll(dest, 0o755)
destFile := filepath.Join(dest, "genesis.json")
if err := fileCopy(src, destFile); err != nil {
simpledialog.Message("%s", "Не удалось скопировать genesis.json: "+err.Error()).Title("Ошибка генерации").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 {
simpledialog.Message("%s", err.Error()).Title("Произошла ошибка").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 == "" {
simpledialog.Message("%s", "Укажите домашнюю директорию узла").Title("Ошибка сохранения").Error()
return
}
cfgp := filepath.Join(homeEntry.Text, "config", "config.toml")
if cfg, err := loadTOML(cfgp); err != nil {
simpledialog.Message("%s", err.Error()).Title("Ошибка чтения TOML").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 == "" {
simpledialog.Message("%s", "Укажите домашнюю директорию узла").Title("Ошибка сохранения").Error()
return
}
cfgp := filepath.Join(homeEntry.Text, "config", "config.toml")
cfg, err := loadTOML(cfgp)
if err != nil {
simpledialog.Message("%s", err.Error()).Title("Ошибка чтения TOML").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 {
simpledialog.Message("%s", err.Error()).Title("Ошибка записи TOML").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 {
simpledialog.Message("%s", err.Error()).Title("Произошла ошибка автозапуска").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(fyne.NewStaticResource("icon.png", iconPNG))
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 {
simpledialog.Message("%s", err.Error()).Title("Произошла ошибка").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) notify(msg string) {
st.app.SendNotification(&fyne.Notification{Title: "LBClient", Content: msg})
}
func (st *AppState) ensurePathsOk(home string) bool {
if home == "" {
simpledialog.Message("%s", "Каталог узла не найден").Title("Не найден HOME").Error()
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()
}