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() }