lbc_launcher/main.go

555 lines
16 KiB
Go
Raw Normal View History

2025-09-16 12:13:15 +03:00
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"
2025-09-21 15:18:18 +03:00
simpledialog "github.com/sqweek/dialog"
2025-09-16 12:13:15 +03:00
_ "embed"
"github.com/emersion/go-autostart"
"github.com/gofrs/flock"
"github.com/gregorybednov/lbc/cli"
2025-09-21 15:18:18 +03:00
"github.com/pelletier/go-toml/v2"
2025-09-16 12:13:15 +03:00
)
//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{}
2025-09-21 15:18:18 +03:00
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)
}
2025-09-16 12:13:15 +03:00
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")
2025-09-21 15:18:18 +03:00
2025-09-16 12:13:15 +03:00
st.app, st.win = a, w
// Статус + логи
st.statusLbl = widget.NewLabel("Готов")
st.logArea = widget.NewMultiLineEntry()
st.logArea.SetPlaceHolder("Здесь будет вывод команд lbc…")
st.logArea.Disable() // только для чтения
homeEntry := widget.NewEntry()
2025-09-21 15:18:18 +03:00
homeEntry.SetPlaceHolder("Полный путь до каталога (там будут данные и настройки)")
2025-09-16 12:13:15 +03:00
homeEntry.SetText(st.cfg.NodeHome)
2025-09-21 15:18:18 +03:00
btnPickHome := widget.NewButton("Выбрать каталог…", func() {
directory, err := simpledialog.Directory().Title("Выбрать каталог с настройками и данными").Browse()
if err == nil {
st.cfg.NodeHome = directory
2025-09-16 12:13:15 +03:00
homeEntry.SetText(st.cfg.NodeHome)
_ = saveConfig(st)
2025-09-21 15:18:18 +03:00
}
2025-09-16 12:13:15 +03:00
})
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 {
2025-09-21 15:18:18 +03:00
simpledialog.Message("%s", err.Error()).Title("Произошла ошибка").Error()
2025-09-16 12:13:15 +03:00
} 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 == "" {
2025-09-21 15:18:18 +03:00
simpledialog.Message("%s", "Сначала укажите домашний каталог узла").Title("Не найден HOME").Error()
2025-09-16 12:13:15 +03:00
return
}
src := filepath.Join(homeEntry.Text, "config", "genesis.json")
if _, err := os.Stat(src); err != nil {
2025-09-21 15:18:18 +03:00
simpledialog.Message("%s", "Не найден файл"+src).Title("Файл не найден").Error()
2025-09-16 12:13:15 +03:00
return
}
2025-09-21 15:18:18 +03:00
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()
2025-09-16 12:13:15 +03:00
})
tabExportGenesis := container.NewVBox(
widget.NewLabel("Экспорт genesis.json из текущего узла"),
exportBtn,
)
// Вкладка: Init joiner
genesisPathEntry := widget.NewEntry()
genesisPathEntry.SetPlaceHolder("Путь к существующему genesis.json")
pickGenesis := widget.NewButton("Выбрать genesis.json…", func() {
2025-09-21 15:18:18 +03:00
filename, err := simpledialog.File().Filter("Genesis JSON file", "json").Load()
if err == nil {
genesisPathEntry.SetText(filename)
}
2025-09-16 12:13:15 +03:00
})
joinBtn := widget.NewButton("Создать узел-присоединение (init joiner)", func() {
if !st.ensurePathsOk(homeEntry.Text) {
return
}
// 1) скопировать genesis.json в HOME/config/genesis.json
src := genesisPathEntry.Text
if src == "" {
2025-09-21 15:18:18 +03:00
simpledialog.Message("%s", "Укажите путь к genesis.json").Title("Не найден файл").Error()
2025-09-16 12:13:15 +03:00
return
}
dest := filepath.Join(homeEntry.Text, "config")
_ = os.MkdirAll(dest, 0o755)
destFile := filepath.Join(dest, "genesis.json")
if err := fileCopy(src, destFile); err != nil {
2025-09-21 15:18:18 +03:00
simpledialog.Message("%s", "Не удалось скопировать genesis.json: "+err.Error()).Title("Ошибка генерации").Error()
2025-09-16 12:13:15 +03:00
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 {
2025-09-21 15:18:18 +03:00
simpledialog.Message("%s", err.Error()).Title("Произошла ошибка").Error()
2025-09-16 12:13:15 +03:00
} 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 == "" {
2025-09-21 15:18:18 +03:00
simpledialog.Message("%s", "Укажите домашнюю директорию узла").Title("Ошибка сохранения").Error()
2025-09-16 12:13:15 +03:00
return
}
cfgp := filepath.Join(homeEntry.Text, "config", "config.toml")
if cfg, err := loadTOML(cfgp); err != nil {
2025-09-21 15:18:18 +03:00
simpledialog.Message("%s", err.Error()).Title("Ошибка чтения TOML").Error()
2025-09-16 12:13:15 +03:00
} 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 == "" {
2025-09-21 15:18:18 +03:00
simpledialog.Message("%s", "Укажите домашнюю директорию узла").Title("Ошибка сохранения").Error()
2025-09-16 12:13:15 +03:00
return
}
cfgp := filepath.Join(homeEntry.Text, "config", "config.toml")
2025-09-21 15:18:18 +03:00
cfg, err := loadTOML(cfgp)
2025-09-16 12:13:15 +03:00
if err != nil {
2025-09-21 15:18:18 +03:00
simpledialog.Message("%s", err.Error()).Title("Ошибка чтения TOML").Error()
2025-09-16 12:13:15 +03:00
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 {
2025-09-21 15:18:18 +03:00
simpledialog.Message("%s", err.Error()).Title("Ошибка записи TOML").Error()
2025-09-16 12:13:15 +03:00
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 {
2025-09-21 15:18:18 +03:00
simpledialog.Message("%s", err.Error()).Title("Произошла ошибка автозапуска").Error()
2025-09-16 12:13:15 +03:00
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 {
2025-09-21 15:18:18 +03:00
desk.SetSystemTrayIcon(fyne.NewStaticResource("icon.png", iconPNG))
2025-09-16 12:13:15 +03:00
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 {
2025-09-21 15:18:18 +03:00
simpledialog.Message("%s", err.Error()).Title("Произошла ошибка").Error()
2025-09-16 12:13:15 +03:00
return
}
} else {
_ = autoApp.Disable()
}
autoItem.Checked = ns
st.cfg.AutoStart = ns
_ = saveConfig(st)
}
2025-09-21 15:18:18 +03:00
quitItem := fyne.NewMenuItem("Выход", func() { releaseSingleInstanceLock(st); a.Quit() })
2025-09-16 12:13:15 +03:00
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
}()
}
2025-09-21 15:18:18 +03:00
2025-09-16 12:13:15 +03:00
func (st *AppState) notify(msg string) {
st.app.SendNotification(&fyne.Notification{Title: "LBClient", Content: msg})
}
2025-09-21 15:18:18 +03:00
2025-09-16 12:13:15 +03:00
func (st *AppState) ensurePathsOk(home string) bool {
if home == "" {
2025-09-21 15:18:18 +03:00
simpledialog.Message("%s", "Каталог узла не найден").Title("Не найден HOME").Error()
2025-09-16 12:13:15 +03:00
return false
}
st.cfg.NodeHome = home
_ = saveConfig(st)
return true
}
2025-09-21 15:18:18 +03:00
2025-09-16 12:13:15 +03:00
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()
}