554 lines
16 KiB
Go
554 lines
16 KiB
Go
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()
|
||
}
|