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