From 14470e0bbc5167fd8b81cc6e76197cafeca192e4 Mon Sep 17 00:00:00 2001 From: Jacob Gadikian Date: Thu, 24 Apr 2025 13:31:19 +0700 Subject: [PATCH] chain registry UI --- .gitignore | 24 +- Makefile | 46 ++ README.md | 429 ++------------- go.mod | 40 ++ go.sum | 80 +++ icon.png | Bin 0 -> 35293 bytes pkg/chainregistry/chainregistry.go | 197 +++++++ pkg/node/node.go | 822 +++++++++++++++++++++++++++++ pkg/ui/ui.go | 801 ++++++++++++++++++++++++++++ 9 files changed, 2063 insertions(+), 376 deletions(-) create mode 100644 Makefile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 icon.png create mode 100644 pkg/chainregistry/chainregistry.go create mode 100644 pkg/node/node.go create mode 100644 pkg/ui/ui.go diff --git a/.gitignore b/.gitignore index 4a295dc9a5..10d3f8bf42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,27 @@ -.idea/ +# Binary +chain-registry-app +chain-registry-app.exe + +# macOS .DS_Store + +# Output directories +/bin/ +/build/ +/dist/ + +# Go specific +/vendor/ +*.o +*.a +*.so + +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo + .github/workflows/utility/__pycache__ node_modules/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..6514adcdb8 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +# Makefile for the Cosmos Chain Registry App + +.PHONY: build run clean lint test package-mac package-windows package-linux package-all + +# Build the application +build: + go build -o chain-registry-app ./cmd/chain-registry-app + +# Run the application +run: build + ./chain-registry-app + +# Clean up build artifacts +clean: + rm -f chain-registry-app + rm -rf fyne-cross + +# Run the linter +lint: + golangci-lint run ./... + +# Run tests +test: + go test ./... + +# Package for macOS +package-mac: + go install fyne.io/fyne/v2/cmd/fyne@latest + fyne package -os darwin -icon icon.png -name "Cosmos Chain Registry" + +# Package for Windows +package-windows: + go install fyne.io/fyne/v2/cmd/fyne@latest + fyne package -os windows -icon icon.png -name "Cosmos Chain Registry" + +# Package for Linux +package-linux: + go install fyne.io/fyne/v2/cmd/fyne@latest + fyne package -os linux -icon icon.png -name "Cosmos Chain Registry" + +# Cross-platform packaging (requires Docker) +package-all: + go install github.com/fyne-io/fyne-cross@latest + fyne-cross darwin -app-id "com.faddat.chain-registry" -icon icon.png + fyne-cross windows -app-id "com.faddat.chain-registry" -icon icon.png + fyne-cross linux -app-id "com.faddat.chain-registry" -icon icon.png \ No newline at end of file diff --git a/README.md b/README.md index aa8db51f79..9ea2417810 100644 --- a/README.md +++ b/README.md @@ -1,401 +1,80 @@ -# Chain Registry +# Cosmos Chain Registry App -This repo contains a `chain.json`, `assetlist.json`, and `versions.json` for a number of cosmos-sdk based chains (and `assetlist.json` for non-cosmos chains). A `chain.json` contains data that makes it easy to start running or interacting with a node. +The Cosmos Chain Registry App is a desktop application that allows users to easily run nodes for various Cosmos-based blockchains. It leverages the [Cosmos Chain Registry](https://github.com/cosmos/chain-registry) to provide up-to-date chain information and configuration. -Schema files containing the recommended metadata structure can be found in the `*.schema.json` files located in the root directory. Schemas are still undergoing revision as user needs are surfaced. Optional fields may be added beyond what is contained in the schema files. +## Features -We invite stakeholders to join the [Cosmos Chain Registry Working Group](https://t.me/+OkM0SDZ-M0liNDdh) on Telegram to discuss major structure changes, ask questions, and develop tooling. +- **Simple GUI Interface**: Easily select and run nodes for different Cosmos chains +- **State Sync Support**: All nodes are run with state sync enabled, allowing for quick bootstrapping +- **Multi-Platform**: Runs on Linux, macOS, and Windows +- **Automatic Binary Management**: Automatically downloads the appropriate binaries for your platform +- **Chain Registry Integration**: Uses the Cosmos Chain Registry for up-to-date chain information -Once schemas have matured and client needs are better understood Chain Registry data is intended to migrate to an on-chain representation hosted on the Cosmos Hub, i.e. the Cosmos Chain Name Service. If you are interested in this effort please join the discussion [here](https://github.com/cosmos/chain-registry/issues/291)! +## Prerequisites -## Npm Modules -- https://www.npmjs.com/package/chain-registry +- Go 1.20 or later +- GCC or Clang compiler (required for Fyne UI) -## Rust Crates -- https://crates.io/crates/chain-registry +### Installing Dependencies -## Web Endpoints -- https://registry.ping.pub (Update every 24H) -- https://proxy.atomscan.com/directory/ (Update every 24H) -- https://cosmoschains.thesilverfox.pro (Updated every 24H) - -## APIs -- https://github.com/cmwaters/skychart -- https://github.com/empowerchain/cosmos-chain-directory -- https://github.com/effofxprime/Cosmregistry-API - -## Web Interfaces -- https://cosmos.directory -- https://chain-registry.netlify.com -- https://atomscan.com/directory - -## Tooling -- https://github.com/gaia/chain-registry-query/ - -## Contributing - -Please give Pull Requests a title that somewhat describes the change more precisely than the default title given to a Commit. PRs titled 'Update chain.json' difficult to navigate when searching through the backlog of Pull Requests. Some recommended details would be: the affected Chain Name, API types, or Provider to give some more detail; e.g., "Add Cosmos Hub APIs for Acme Validator". - -### Endpoints reachability - -The endpoints added here are being tested via CI daily at 00:00 UTC. It is expected that your endpoints return an HTTP 200 in the following paths: -- rest: `/cosmos/base/tendermint/v1beta1/syncing` -- rpc: `/status` -- grpc: not tested -Endpoints that consistently fail to respond successfully may be removed without warning. - -Providers ready to be tested daily should be whitelisted here: `.github/workflows/tests/apis.py` - -# chain.json - -## Sample - -A sample `chain.json` includes the following information. - -```json -{ - "$schema": "../chain.schema.json", - "chain_name": "osmosis", - "status": "live", - "website": "https://osmosis.zone/", - "network_type": "mainnet", - "chain_type": "cosmos" - "pretty_name": "Osmosis", - "chain_id": "osmosis-1", - "bech32_prefix": "osmo", - "daemon_name": "osmosisd", - "node_home": "$HOME/.osmosisd", - "key_algos": [ - "secp256k1" - ], - "slip44": 118, - "fees": { - "fee_tokens": [ - { - "denom": "uosmo", - "fixed_min_gas_price": 0, - "low_gas_price": 0, - "average_gas_price": 0.025, - "high_gas_price": 0.04 - } - ] - }, - "staking": { - "staking_tokens": [ - { - "denom": "uosmo" - } - ], - "lock_duration": { - "time": "1209600s" - } - }, - "codebase": { - "git_repo": "https://github.com/osmosis-labs/osmosis", - "genesis": { - "name": "v3", - "genesis_url": "https://github.com/osmosis-labs/networks/raw/main/osmosis-1/genesis.json" - }, - "recommended_version": "v25.0.0" - }, - "images": [ - { - "svg": "https://raw.githubusercontent.com/cosmos/chain-registry/master/osmosis/images/osmosis-chain-logo.svg", - "png": "https://raw.githubusercontent.com/cosmos/chain-registry/master/osmosis/images/osmosis-chain-logo.png", - "theme": { - "circle": true, - "primary_color_hex": "#231D4B" - } - } - ], - "peers": { - "seeds": [ - { - "id": "83adaa38d1c15450056050fd4c9763fcc7e02e2c", - "address": "ec2-44-234-84-104.us-west-2.compute.amazonaws.com:26656", - "provider": "notional" - }, - ... - { - //another peer - } - ], - "persistent_peers": [ - { - "id": "8f67a2fcdd7ade970b1983bf1697111d35dfdd6f", - "address": "52.79.199.137:26656", - "provider": "cosmostation" - }, - ... - { - //another peer - } - ] - }, - "apis": { - "rpc": [ - { - "address": "https://osmosis.validator.network/", - "provider": "validatornetwork" - }, - ... - { - //another rpc - } - ], - "rest": [ - { - "address": "https://lcd-osmosis.blockapsis.com", - "provider": "chainapsis" - }, - ... - { - //another rest - } - ] - }, - "explorers": [ - { - "kind": "mintscan", - "url": "https://www.mintscan.io/osmosis", - "tx_page": "https://www.mintscan.io/osmosis/txs/${txHash}", - "account_page": "https://www.mintscan.io/osmosis/account/${accountAddress}" - }, - ... - { - //another explorer - } - ], - "keywords": [ - "dex" - ] -} +#### macOS +```bash +brew install go gcc ``` -### Guidelines for Properties - -#### Bech32 Prefix -Although it is not a requirement that bech32 prefixes be unique, it is highly recommended for each chain to have its bech32 prefix registered at the Satoshi Labs Registry (see [SLIP-0173 : Registered human-readable parts for BIP-0173](https://github.com/satoshilabs/slips/blob/master/slip-0173.md)), or consider picking an uncliamed prefix if the chosen prefix has already be registered to another project. - -# Assetlists +#### Ubuntu/Debian +```bash +sudo apt-get install -y golang build-essential libgl1-mesa-dev xorg-dev +``` -Asset Lists are inspired by the [Token Lists](https://tokenlists.org/) project on Ethereum which helps discoverability of ERC20 tokens by providing a mapping between erc20 contract addresses and their associated metadata. +#### Windows +Install Go from [golang.org](https://golang.org/dl/) and MinGW from [mingw-w64.org](https://www.mingw-w64.org/) or MSYS2 from [msys2.org](https://www.msys2.org/). -Asset lists are a similar mechanism to allow frontends and other UIs to fetch metadata associated with Cosmos SDK denoms, especially for assets sent over IBC. +## Installation -This standard is a work in progress. You'll notice that the format of `assets` in the assetlist.json structure is a strict superset json representation of the [`banktypes.DenomMetadata`](https://docs.cosmos.network/main/build/architecture/adr-024-coin-metadata) from the Cosmos SDK. This is purposefully done so that this standard may eventually be migrated into a Cosmos SDK module in the future, so it can be easily maintained on chain instead of on Github. +1. Clone this repository: +```bash +git clone https://github.com/faddat/chain-registry.git +cd chain-registry +``` -The assetlist JSON Schema can be found [here](/assetlist.schema.json). +2. Build the application: +```bash +go mod tidy +go build -o chain-registry-app ./cmd/chain-registry-app +``` -An example assetlist json contains the following structure: +## Usage -```json -{ - "$schema": "../assetlist.schema.json", - "chain_name": "osmosis", - "assets": [ - { - "description": "The native token of Osmosis", - "denom_units": [ - { - "denom": "uosmo", - "exponent": 0 - }, - { - "denom": "osmo", - "exponent": 6 - } - ], - "type_asset": "sdk.coin", - "base": "uosmo", - "name": "Osmosis", - "display": "osmo", - "symbol": "OSMO", - "images": [ - { - "png": "https://raw.githubusercontent.com/cosmos/chain-registry/master/osmosis/images/osmo.png", - "svg": "https://raw.githubusercontent.com/cosmos/chain-registry/master/osmosis/images/osmo.svg", - "theme": { - "circle": false, - "primary_color_hex": "#5c09a0" - } - } - ], - "coingecko_id": "osmosis", - "keywords": [ - "dex", - "staking" - ], - "socials": { - "website": "https://osmosis.zone", - "twitter": "https://twitter.com/osmosiszone" - } - }, - .. - { - "description": "The native staking and governance token of the Cosmos Hub.", - "denom_units": [ - { - "denom": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", - "exponent": 0, - "aliases": [ - "uatom" - ] - }, - { - "denom": "atom", - "exponent": 6 - } - ], - "type_asset": "ics20", - "base": "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", - "name": "Cosmos Hub", - "display": "atom", - "symbol": "ATOM", - "traces": [ - { - "type": "ibc", - "counterparty": { - "chain_name": "cosmoshub", - "base_denom": "uatom", - "channel_id": "channel-141" - }, - "chain": { - "channel_id": "channel-0", - "path": "transfer/channel-0/uatom" - } - } - ], - "images": [ - { - "image_sync": { - "chain_name": "cosmoshub", - "base_denom": "uatom" - }, - "png": "https://raw.githubusercontent.com/cosmos/chain-registry/master/cosmoshub/images/atom.png", - "svg": "https://raw.githubusercontent.com/cosmos/chain-registry/master/cosmoshub/images/atom.svg", - "theme": { - "primary_color_hex": "#272d45" - } - } - ] - } - ] -} +Run the application: +```bash +./chain-registry-app ``` -## IBC Data - -The metadata contained in these files represents a path abstraction between two IBC-connected networks. This information is particularly useful when relaying packets and acknowledgments across chains. +1. Select a chain from the dropdown list +2. Click "Start Node" to download the binary, initialize, and start the node +3. Monitor progress in the logs area +4. Stop the node using the "Stop Node" button when done -This schema also allows us to provide helpful info to describe open channels. +## Data Storage -Note: when creating these files, please ensure the chains in both the file name and the references of `chain-1` and `chain-2` in the json file are in alphabetical order. Ex: `Achain-Zchain.json`. The chain names used must match name of the chain's directory here in the chain-registry. +The application stores all node data in the `~/.chain-registry-app` directory, including: +- Chain binaries +- Node data directories +- Configuration files -An example ibc metadata file contains the following structure: +## Development -```json -{ - "$schema": "../ibc_data.schema.json", - "chain_1": { - "chain_name": "juno", - "client_id": "07-tendermint-0", - "connection_id": "connection-0" - }, - "chain_2": { - "chain_name": "osmosis", - "client_id": "07-tendermint-1457", - "connection_id": "connection-1142" - }, - "channels": [ - { - "chain_1": { - "channel_id": "channel-0", - "port_id": "transfer" - }, - "chain_2": { - "channel_id": "channel-42", - "port_id": "transfer" - }, - "ordering": "unordered", - "version": "ics20-1", - "tags": { - "status": "live", - "preferred": true, - "dex": "osmosis" - } - } - ] -} +To rebuild the application after making changes: +```bash +go build -o chain-registry-app ./cmd/chain-registry-app ``` +## License -## Versions - -The metadata contained in these files represents a path abstraction between two IBC-connected networks. This information is particularly useful when relaying packets and acknowledgments across chains. +This project is licensed under the MIT License - see the LICENSE file for details. -An example ibc metadata file contains the following structure: - -```json -{ - "$schema": "../ibc_data.schema.json", - "chain_name": "osmosis", - "versions": [ - { - "name": "v3", - "tag": "v3.1.0", - "height": 0, - "next_version_name": "v4" - }, - ...//entire version history, an object for each major version - { - "name": "v25", - "tag": "v25.0.0", - "proposal": 782, - "height": 15753500, - "recommended_version": "v25.0.0", - "compatible_versions": [ - "v25.0.0" - ], - "binaries": { - "linux/amd64": "https://github.com/osmosis-labs/osmosis/releases/download/v25.0.0/osmosisd-25.0.0-linux-amd64", - "linux/arm64": "https://github.com/osmosis-labs/osmosis/releases/download/v25.0.0/osmosisd-25.0.0-linux-arm64" - }, - "previous_version_name": "v24", - "next_version_name": "v26", - "consensus": { - "type": "cometbft", - "version": "0.37.4", - "repo": "https::github.com/osmosis-labs/cometbft", - "tag": "v0.37.4-v25-osmo-2" - }, - "cosmwasm": { - "version": "0.45.0", - "repo": "https://github.com/osmosis-labs/wasmd", - "tag": "v0.45.0-osmo", - "enabled": true - }, - "sdk": { - "type": "cosmos", - "version": "0.47.5", - "repo": "https://github.com/osmosis-labs/cosmos-sdk", - "tag": "v0.47.5-v25-osmo-1" - }, - "ibc": { - "type": "go", - "version": "7.4.0", - "ics_enabled": [ - "ics20-1" - ] - }, - "language": { - "type": "go", - "version": "1.21.4" - } - } - ] -} -``` ---- +## Acknowledgments -Creative Commons Licence
This work is licensed under a Creative Commons Attribution 4.0 International License. +- [Cosmos Chain Registry](https://github.com/cosmos/chain-registry) for chain data +- [Fyne](https://fyne.io/) for the pure Go UI toolkit diff --git a/go.mod b/go.mod new file mode 100644 index 0000000000..f3eee1f67d --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module github.com/faddat/chain-registry + +go 1.24.1 + +require fyne.io/fyne/v2 v2.6.0 + +require ( + fyne.io/systray v1.11.0 // indirect + github.com/BurntSushi/toml v1.4.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fredbi/uri v1.1.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fyne-io/gl-js v0.1.0 // indirect + github.com/fyne-io/glfw-js v0.2.0 // indirect + github.com/fyne-io/image v0.1.1 // indirect + github.com/fyne-io/oksvg v0.1.0 // indirect + github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect + github.com/go-text/render v0.2.0 // indirect + github.com/go-text/typesetting v0.2.1 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/hack-pad/go-indexeddb v0.3.2 // indirect + github.com/hack-pad/safejs v0.1.0 // indirect + github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect + github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rymdport/portal v0.4.1 // indirect + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/yuin/goldmark v1.7.8 // indirect + golang.org/x/image v0.24.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000..5e657b2ad7 --- /dev/null +++ b/go.sum @@ -0,0 +1,80 @@ +fyne.io/fyne/v2 v2.6.0 h1:Rywo9yKYN4qvNuvkRuLF+zxhJYWbIFM+m4N4KV4p1pQ= +fyne.io/fyne/v2 v2.6.0/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU= +fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= +fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= +github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM= +github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= +github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM= +github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= +github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw= +github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= +github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= +github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= +github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= +github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= +github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= +github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= +github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc= +github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= +github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA= +github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..509c1f143295372b39d10b50bbff6b19ff9a526f GIT binary patch literal 35293 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 0 { + fmt.Fprintf(n.LogWriter, "File size: %.2f MB\n\n", float64(contentLength)/(1024*1024)) + } else { + fmt.Fprintf(n.LogWriter, "File size: unknown\n\n") + } + + // Create the binary file + out, err := os.OpenFile(n.BinaryPath, os.O_CREATE|os.O_WRONLY, 0755) + if err != nil { + return fmt.Errorf("failed to create binary file: %w", err) + } + defer out.Close() + + // Create a progress tracking wrapper + var downloaded int64 + progressInterval := time.NewTicker(time.Second) + defer progressInterval.Stop() + + done := make(chan bool) + defer close(done) + + // Progress reporting goroutine + go func() { + lastPercent := -1 + for { + select { + case <-progressInterval.C: + if contentLength > 0 { + percent := int(float64(downloaded) / float64(contentLength) * 100) + if percent != lastPercent && percent <= 100 { + fmt.Fprintf(n.LogWriter, "Download progress: %d%% (%.2f MB / %.2f MB)\n", + percent, + float64(downloaded)/(1024*1024), + float64(contentLength)/(1024*1024)) + lastPercent = percent + } + } else { + fmt.Fprintf(n.LogWriter, "Downloaded: %.2f MB\n", float64(downloaded)/(1024*1024)) + } + case <-done: + return + } + } + }() + + // Write the binary to disk with progress tracking + reader := &ProgressReader{ + Reader: resp.Body, + OnRead: func(n int) { + downloaded += int64(n) + }, + } + + _, err = io.Copy(out, reader) + if err != nil { + return fmt.Errorf("failed to write binary to disk: %w", err) + } + + // Signal progress reporting to complete + done <- true + + // Force one final progress update at 100% + if contentLength > 0 { + fmt.Fprintf(n.LogWriter, "Download progress: 100%% (%.2f MB / %.2f MB)\n", + float64(downloaded)/(1024*1024), + float64(contentLength)/(1024*1024)) + } + + fmt.Fprintf(n.LogWriter, "\n===== DOWNLOAD COMPLETED SUCCESSFULLY =====\n") + fmt.Fprintf(n.LogWriter, "Binary downloaded to %s\n\n", n.BinaryPath) + return nil +} + +// ProgressReader is a wrapper that reports read progress +type ProgressReader struct { + Reader io.Reader + OnRead func(n int) +} + +// Read implements io.Reader +func (pr *ProgressReader) Read(p []byte) (n int, err error) { + n, err = pr.Reader.Read(p) + if n > 0 && pr.OnRead != nil { + pr.OnRead(n) + } + return +} + +// CompileFromSource clones the repository and builds the binary from source +func (n *Node) CompileFromSource() error { + // Check if git is installed + if _, err := exec.LookPath("git"); err != nil { + return fmt.Errorf("git is required to build from source: %w", err) + } + + // Check if go is installed + if _, err := exec.LookPath("go"); err != nil { + return fmt.Errorf("go is required to build from source: %w", err) + } + + fmt.Fprintf(n.LogWriter, "===== STARTING BUILD PROCESS =====\n") + + // Create a source directory + sourceDir := filepath.Join(n.DataDir, "source", n.Chain.ChainName) + if err := os.MkdirAll(sourceDir, 0755); err != nil { + return fmt.Errorf("failed to create source directory: %w", err) + } + + // Check if the git repo is specified + if n.Chain.Codebase.GitRepo == "" { + return fmt.Errorf("git repository is not specified in chain info") + } + + // Clone the repository if it doesn't exist + repoPath := filepath.Join(sourceDir, "repo") + if _, err := os.Stat(repoPath); os.IsNotExist(err) { + fmt.Fprintf(n.LogWriter, "\n===== CLONING REPOSITORY =====\n") + fmt.Fprintf(n.LogWriter, "Source: %s\n", n.Chain.Codebase.GitRepo) + fmt.Fprintf(n.LogWriter, "Destination: %s\n\n", repoPath) + + cmd := exec.Command("git", "clone", n.Chain.Codebase.GitRepo, repoPath) + cmd.Stdout = n.LogWriter + cmd.Stderr = n.LogWriter + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to clone repository: %w", err) + } + } + + // Checkout the recommended version if specified + if n.Chain.Codebase.RecommendedVersion != "" { + fmt.Fprintf(n.LogWriter, "\n===== CHECKING OUT VERSION %s =====\n\n", n.Chain.Codebase.RecommendedVersion) + cmd := exec.Command("git", "checkout", n.Chain.Codebase.RecommendedVersion) + cmd.Dir = repoPath + cmd.Stdout = n.LogWriter + cmd.Stderr = n.LogWriter + if err := cmd.Run(); err != nil { + fmt.Fprintf(n.LogWriter, "Warning: Failed to checkout version %s: %v\n", n.Chain.Codebase.RecommendedVersion, err) + // Try to fetch updates and retry + fmt.Fprintf(n.LogWriter, "\n===== FETCHING UPDATES =====\n\n") + fetchCmd := exec.Command("git", "fetch", "--all") + fetchCmd.Dir = repoPath + fetchCmd.Stdout = n.LogWriter + fetchCmd.Stderr = n.LogWriter + if fetchErr := fetchCmd.Run(); fetchErr != nil { + return fmt.Errorf("failed to fetch updates: %w", fetchErr) + } + + // Retry checkout + fmt.Fprintf(n.LogWriter, "\n===== RETRYING CHECKOUT =====\n\n") + cmd = exec.Command("git", "checkout", n.Chain.Codebase.RecommendedVersion) + cmd.Dir = repoPath + cmd.Stdout = n.LogWriter + cmd.Stderr = n.LogWriter + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to checkout version after fetch: %w", err) + } + } + } + + // Build the binary + fmt.Fprintf(n.LogWriter, "\n===== BUILDING BINARY =====\n") + fmt.Fprintf(n.LogWriter, "Command: go build -o %s ./cmd/%s\n\n", n.BinaryPath, n.Binary) + + buildCmd := exec.Command("go", "build", "-o", n.BinaryPath, "./cmd/"+n.Binary) + buildCmd.Dir = repoPath + buildCmd.Stdout = n.LogWriter + buildCmd.Stderr = n.LogWriter + if err := buildCmd.Run(); err != nil { + // Try finding the main package if the default path doesn't work + fmt.Fprintf(n.LogWriter, "\n===== DEFAULT BUILD FAILED, TRYING ALTERNATE METHODS =====\n") + + // Check if main.go exists in the repo root + if _, err := os.Stat(filepath.Join(repoPath, "main.go")); err == nil { + fmt.Fprintf(n.LogWriter, "\n===== ATTEMPTING BUILD FROM ROOT DIRECTORY =====\n\n") + rootBuildCmd := exec.Command("go", "build", "-o", n.BinaryPath) + rootBuildCmd.Dir = repoPath + rootBuildCmd.Stdout = n.LogWriter + rootBuildCmd.Stderr = n.LogWriter + if rootBuildErr := rootBuildCmd.Run(); rootBuildErr == nil { + fmt.Fprintf(n.LogWriter, "Binary built successfully using root directory build\n") + fmt.Fprintf(n.LogWriter, "\n===== BUILD PROCESS COMPLETED SUCCESSFULLY =====\n\n") + return nil + } + } + + // Try to find the entry point and build using make + fmt.Fprintf(n.LogWriter, "\n===== TRYING 'make install' (WITHOUT -mod=readonly) =====\n\n") + + // First try to update go.mod if needed + goModUpdateCmd := exec.Command("go", "mod", "tidy") + goModUpdateCmd.Dir = repoPath + goModUpdateCmd.Stdout = n.LogWriter + goModUpdateCmd.Stderr = n.LogWriter + goModUpdateCmd.Run() // Ignore errors, just try it + + // Some projects use 'make build' instead of 'make install' + makeBuildCmd := exec.Command("make", "build") + makeBuildCmd.Dir = repoPath + makeBuildCmd.Stdout = n.LogWriter + makeBuildCmd.Stderr = n.LogWriter + if makeBuildErr := makeBuildCmd.Run(); makeBuildErr == nil { + // Look for the binary in the build directory + buildDir := filepath.Join(repoPath, "build") + if _, err := os.Stat(buildDir); err == nil { + // Find the binary in the build directory + entries, err := os.ReadDir(buildDir) + if err == nil && len(entries) > 0 { + for _, entry := range entries { + if !entry.IsDir() && (entry.Name() == n.Binary || strings.Contains(entry.Name(), n.Chain.ChainName)) { + buildBinary := filepath.Join(buildDir, entry.Name()) + if copyErr := copyFile(buildBinary, n.BinaryPath); copyErr == nil { + fmt.Fprintf(n.LogWriter, "Binary built with 'make build' and copied from %s\n", buildBinary) + fmt.Fprintf(n.LogWriter, "\n===== BUILD PROCESS COMPLETED SUCCESSFULLY =====\n\n") + return nil + } + } + } + } + } + } + + // Try make install without readonly flag + makeCmd := exec.Command("make", "install") + makeCmd.Dir = repoPath + makeCmd.Stdout = n.LogWriter + makeCmd.Stderr = n.LogWriter + + // Set environment variables to remove readonly flag if present in Makefile + makeCmd.Env = append(os.Environ(), "GO_MOD_FLAGS=") + + var makeErrOutput bytes.Buffer + makeCmd.Stderr = io.MultiWriter(n.LogWriter, &makeErrOutput) + + if makeErr := makeCmd.Run(); makeErr != nil { + // Check if the error is about readonly flag + if strings.Contains(makeErrOutput.String(), "updates to go.mod needed, disabled by -mod=readonly") { + fmt.Fprintf(n.LogWriter, "\n===== DETECTED -mod=readonly ERROR, TRYING DIRECT GO INSTALL =====\n\n") + + // Try direct 'go install' without make + goInstallCmd := exec.Command("go", "install", "./cmd/"+n.Binary) + goInstallCmd.Dir = repoPath + goInstallCmd.Stdout = n.LogWriter + goInstallCmd.Stderr = n.LogWriter + if installErr := goInstallCmd.Run(); installErr != nil { + // Try a broader search + fmt.Fprintf(n.LogWriter, "\n===== TRYING BROADER SEARCH FOR ENTRY POINT =====\n\n") + + // Use find to locate main packages + findCmd := exec.Command("find", ".", "-type", "f", "-name", "*.go", "-exec", "grep", "-l", "func main", "{}", ";") + findCmd.Dir = repoPath + var outBuf bytes.Buffer + findCmd.Stdout = &outBuf + findCmd.Run() // Ignore errors + + // Try building each found main package + mainFiles := strings.Split(outBuf.String(), "\n") + for _, mainFile := range mainFiles { + if mainFile == "" { + continue + } + + // Get directory containing the main file + mainDir := filepath.Dir(mainFile) + fmt.Fprintf(n.LogWriter, "Trying to build main package found in: %s\n", mainDir) + + buildMainCmd := exec.Command("go", "build", "-o", n.BinaryPath, mainDir) + buildMainCmd.Dir = repoPath + buildMainCmd.Stdout = n.LogWriter + buildMainCmd.Stderr = n.LogWriter + if buildErr := buildMainCmd.Run(); buildErr == nil { + fmt.Fprintf(n.LogWriter, "Successfully built binary from %s\n", mainDir) + fmt.Fprintf(n.LogWriter, "\n===== BUILD PROCESS COMPLETED SUCCESSFULLY =====\n\n") + return nil + } + } + } else { + // go install succeeded, find and copy the binary + gopath := os.Getenv("GOPATH") + if gopath == "" { + gopath = filepath.Join(os.Getenv("HOME"), "go") + } + binPath := filepath.Join(gopath, "bin", n.Binary) + if _, statErr := os.Stat(binPath); statErr == nil { + if copyErr := copyFile(binPath, n.BinaryPath); copyErr == nil { + fmt.Fprintf(n.LogWriter, "Binary built with 'go install' and copied from %s\n", binPath) + fmt.Fprintf(n.LogWriter, "\n===== BUILD PROCESS COMPLETED SUCCESSFULLY =====\n\n") + return nil + } + } + } + } + + // Check if the binary was installed in GOPATH despite make errors + gopath := os.Getenv("GOPATH") + if gopath == "" { + gopath = filepath.Join(os.Getenv("HOME"), "go") + } + binPath := filepath.Join(gopath, "bin", n.Binary) + if _, statErr := os.Stat(binPath); statErr == nil { + // Copy the binary to our destination + fmt.Fprintf(n.LogWriter, "\n===== BINARY FOUND IN GOPATH, COPYING TO DESTINATION =====\n\n") + if copyErr := copyFile(binPath, n.BinaryPath); copyErr != nil { + return fmt.Errorf("failed to copy binary: %w", copyErr) + } + fmt.Fprintf(n.LogWriter, "Binary installed to %s\n", n.BinaryPath) + fmt.Fprintf(n.LogWriter, "\n===== BUILD PROCESS COMPLETED SUCCESSFULLY =====\n\n") + return nil + } + + return fmt.Errorf("failed to build binary: %w", makeErr) + } + + // If make install succeeds, check if the binary was installed in GOPATH + gopath := os.Getenv("GOPATH") + if gopath == "" { + gopath = filepath.Join(os.Getenv("HOME"), "go") + } + binPath := filepath.Join(gopath, "bin", n.Binary) + if _, statErr := os.Stat(binPath); statErr == nil { + // Copy the binary to our destination + fmt.Fprintf(n.LogWriter, "\n===== COPYING BINARY FROM GOPATH TO DESTINATION =====\n\n") + if copyErr := copyFile(binPath, n.BinaryPath); copyErr != nil { + return fmt.Errorf("failed to copy binary: %w", copyErr) + } + fmt.Fprintf(n.LogWriter, "Binary built and installed to %s\n", n.BinaryPath) + fmt.Fprintf(n.LogWriter, "\n===== BUILD PROCESS COMPLETED SUCCESSFULLY =====\n\n") + return nil + } + + return fmt.Errorf("failed to build binary and couldn't find installed binary") + } + + fmt.Fprintf(n.LogWriter, "Binary built successfully at %s\n", n.BinaryPath) + fmt.Fprintf(n.LogWriter, "\n===== BUILD PROCESS COMPLETED SUCCESSFULLY =====\n\n") + return nil +} + +// copyFile copies a file from src to dst +func copyFile(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return fmt.Errorf("failed to open source file: %w", err) + } + defer srcFile.Close() + + dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0755) + if err != nil { + return fmt.Errorf("failed to create destination file: %w", err) + } + defer dstFile.Close() + + _, err = io.Copy(dstFile, srcFile) + if err != nil { + return fmt.Errorf("failed to copy file: %w", err) + } + + return nil +} + +// Initialize initializes the node +func (n *Node) Initialize() error { + // Create the node config directory if it doesn't exist + if err := os.MkdirAll(n.HomeDir, 0755); err != nil { + return fmt.Errorf("failed to create node home directory: %w", err) + } + + // Check if the node is already initialized + configPath := filepath.Join(n.HomeDir, "config", "config.toml") + if _, err := os.Stat(configPath); err == nil { + fmt.Fprintf(n.LogWriter, "Node already initialized at %s\n", n.HomeDir) + return nil + } + + // Initialize the node + cmd := exec.Command(n.BinaryPath, "init", "chain-registry-app", "--home", n.HomeDir, "--chain-id", n.Chain.ChainID) + var outBuf, errBuf bytes.Buffer + cmd.Stdout = io.MultiWriter(&outBuf, n.LogWriter) + cmd.Stderr = io.MultiWriter(&errBuf, n.LogWriter) + + fmt.Fprintf(n.LogWriter, "Initializing node with command: %s\n", cmd.String()) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to initialize node: %w, stderr: %s", err, errBuf.String()) + } + + fmt.Fprintf(n.LogWriter, "Node initialized at %s\n", n.HomeDir) + return nil +} + +// SetupStateSync sets up state sync configuration +func (n *Node) SetupStateSync() error { + // Find a reliable RPC endpoint for state sync + var rpcEndpoint string + for _, rpc := range n.Chain.APIs.RPC { + if strings.HasPrefix(rpc.Address, "https://") { + rpcEndpoint = rpc.Address + break + } + } + + if rpcEndpoint == "" && len(n.Chain.APIs.RPC) > 0 { + rpcEndpoint = n.Chain.APIs.RPC[0].Address + } + + if rpcEndpoint == "" { + return fmt.Errorf("no RPC endpoint available for state sync") + } + + // Get the chain status from the RPC endpoint + fmt.Fprintf(n.LogWriter, "Getting chain status from %s\n", rpcEndpoint) + resp, err := http.Get(fmt.Sprintf("%s/status", rpcEndpoint)) + if err != nil { + return fmt.Errorf("failed to get chain status: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to get chain status, status code: %d", resp.StatusCode) + } + + // Parse the response + var status struct { + Result struct { + SyncInfo struct { + LatestBlockHeight string `json:"latest_block_height"` + } `json:"sync_info"` + } `json:"result"` + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if err := json.Unmarshal(body, &status); err != nil { + return fmt.Errorf("failed to unmarshal status response: %w", err) + } + + // Check if the latest block height is available + if status.Result.SyncInfo.LatestBlockHeight == "" { + return fmt.Errorf("latest block height not available in status response") + } + + // Convert the latest block height to an integer + latestHeight, err := strconv.Atoi(status.Result.SyncInfo.LatestBlockHeight) + if err != nil { + return fmt.Errorf("failed to convert latest block height: %w", err) + } + + // Calculate the trust height and trust hash + trustHeight := latestHeight - 2000 + if trustHeight < 1 { + trustHeight = 1 + } + + // Get the block hash at the trust height + fmt.Fprintf(n.LogWriter, "Getting block at height %d\n", trustHeight) + resp, err = http.Get(fmt.Sprintf("%s/block?height=%d", rpcEndpoint, trustHeight)) + if err != nil { + return fmt.Errorf("failed to get block at height %d: %w", trustHeight, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to get block at height %d, status code: %d", trustHeight, resp.StatusCode) + } + + // Parse the response + var block struct { + Result struct { + BlockID struct { + Hash string `json:"hash"` + } `json:"block_id"` + } `json:"result"` + } + + body, err = io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if err := json.Unmarshal(body, &block); err != nil { + return fmt.Errorf("failed to unmarshal block response: %w", err) + } + + // Check if the block hash is available + if block.Result.BlockID.Hash == "" { + return fmt.Errorf("block hash not available in block response") + } + + trustHash := block.Result.BlockID.Hash + + // Update the config.toml file + configPath := filepath.Join(n.HomeDir, "config", "config.toml") + file, err := os.Open(configPath) + if err != nil { + return fmt.Errorf("failed to open config.toml: %w", err) + } + defer file.Close() + + // Read the config.toml file + var configLines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + configLines = append(configLines, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("failed to read config.toml: %w", err) + } + + // Modify the config.toml file + var modifiedLines []string + for _, line := range configLines { + // Update the state sync configuration + if strings.HasPrefix(line, "enable = ") && strings.Contains(line, "[statesync]") { + modifiedLines = append(modifiedLines, "enable = true") + } else if strings.HasPrefix(line, "rpc_servers = ") { + modifiedLines = append(modifiedLines, fmt.Sprintf(`rpc_servers = "%s,%s"`, rpcEndpoint, rpcEndpoint)) + } else if strings.HasPrefix(line, "trust_height = ") { + modifiedLines = append(modifiedLines, fmt.Sprintf("trust_height = %d", trustHeight)) + } else if strings.HasPrefix(line, "trust_hash = ") { + modifiedLines = append(modifiedLines, fmt.Sprintf(`trust_hash = "%s"`, trustHash)) + } else if strings.HasPrefix(line, "discovery_time = ") { + modifiedLines = append(modifiedLines, "discovery_time = \"30s\"") + } else { + modifiedLines = append(modifiedLines, line) + } + } + + // Write the modified config.toml file + if err := os.WriteFile(configPath, []byte(strings.Join(modifiedLines, "\n")), 0644); err != nil { + return fmt.Errorf("failed to write config.toml: %w", err) + } + + // Update class variables + n.StateSyncRPC = rpcEndpoint + if len(n.Chain.Peers.Seeds) > 0 { + n.StateSyncPeers = []string{n.Chain.Peers.Seeds[0].Address} + } + + fmt.Fprintf(n.LogWriter, "State sync configured with RPC %s and trust height %d\n", rpcEndpoint, trustHeight) + return nil +} + +// Start starts the node +func (n *Node) Start() error { + if n.cmd != nil && n.cmd.Process != nil { + return fmt.Errorf("node is already running") + } + + // Build the command + args := []string{ + "start", + "--home", n.HomeDir, + } + + cmd := exec.Command(n.BinaryPath, args...) + cmd.Stdout = n.LogWriter + cmd.Stderr = n.LogWriter + + fmt.Fprintf(n.LogWriter, "Starting node with command: %s\n", cmd.String()) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start node: %w", err) + } + + n.cmd = cmd + fmt.Fprintf(n.LogWriter, "Node started with PID: %d\n", cmd.Process.Pid) + return nil +} + +// Stop stops the node +func (n *Node) Stop() error { + if n.cmd == nil || n.cmd.Process == nil { + return nil + } + + fmt.Fprintf(n.LogWriter, "Stopping node with PID: %d\n", n.cmd.Process.Pid) + if err := n.cmd.Process.Signal(os.Interrupt); err != nil { + return fmt.Errorf("failed to send interrupt signal: %w", err) + } + + // Wait for the process to exit with a timeout + done := make(chan error, 1) + go func() { + done <- n.cmd.Wait() + }() + + select { + case err := <-done: + if err != nil { + return fmt.Errorf("node exited with error: %w", err) + } + case <-time.After(10 * time.Second): + if err := n.cmd.Process.Kill(); err != nil { + return fmt.Errorf("failed to kill node process: %w", err) + } + return fmt.Errorf("node did not exit gracefully, killed after timeout") + } + + n.cmd = nil + fmt.Fprintf(n.LogWriter, "Node stopped\n") + return nil +} + +// Status returns the status of the node +func (n *Node) Status() (string, error) { + if n.cmd == nil || n.cmd.Process == nil { + return "Not running", nil + } + + process, err := os.FindProcess(n.cmd.Process.Pid) + if err != nil { + return "Unknown", fmt.Errorf("failed to find process: %w", err) + } + + // Send a signal 0 to check if the process exists + if err := process.Signal(syscall.Signal(0)); err != nil { + return "Not running", nil + } + + return "Running", nil +} + +// IsRunning returns true if the node is running +func (n *Node) IsRunning() bool { + status, _ := n.Status() + return status == "Running" +} + +// SetMinGasPrices sets the minimum gas prices in app.toml +func (n *Node) SetMinGasPrices(prices string) error { + // Check if app.toml exists + appTomlPath := filepath.Join(n.HomeDir, "config", "app.toml") + if _, err := os.Stat(appTomlPath); err != nil { + return fmt.Errorf("app.toml not found: %w", err) + } + + // Read the app.toml file + data, err := os.ReadFile(appTomlPath) + if err != nil { + return fmt.Errorf("failed to read app.toml: %w", err) + } + + // Convert content to lines + lines := strings.Split(string(data), "\n") + + // Find and update minimum-gas-prices + updated := false + for i, line := range lines { + if strings.HasPrefix(line, "minimum-gas-prices") { + lines[i] = fmt.Sprintf(`minimum-gas-prices = "%s"`, prices) + updated = true + break + } + } + + // If we didn't find the setting, we should add it + if !updated { + fmt.Fprintf(n.LogWriter, "Warning: minimum-gas-prices setting not found in app.toml, this might not be a valid config file\n") + return fmt.Errorf("minimum-gas-prices setting not found in app.toml") + } + + // Write the modified app.toml back to disk + if err := os.WriteFile(appTomlPath, []byte(strings.Join(lines, "\n")), 0644); err != nil { + return fmt.Errorf("failed to write app.toml: %w", err) + } + + fmt.Fprintf(n.LogWriter, "Set minimum-gas-prices to %s in %s\n", prices, appTomlPath) + return nil +} diff --git a/pkg/ui/ui.go b/pkg/ui/ui.go new file mode 100644 index 0000000000..5c37c66c48 --- /dev/null +++ b/pkg/ui/ui.go @@ -0,0 +1,801 @@ +// Package ui provides the UI for the Chain Registry app +package ui + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "github.com/faddat/chain-registry/pkg/chainregistry" + "github.com/faddat/chain-registry/pkg/node" +) + +// UI represents the UI state +type UI struct { + App fyne.App + Window fyne.Window + ChainSelector *widget.Select + NodeSelector *widget.Select // New selector for switching between running nodes + LogText *widget.Entry // Using Entry for text display + StartButton *widget.Button + StopButton *widget.Button + StatusLabel *widget.Label + ProgressBar *widget.ProgressBar + Registry *chainregistry.Registry + ActiveNodes map[string]*node.Node // Map of chain names to running nodes + DisplayedNode string // Currently displayed node + DataDir string + LogWriter io.Writer + NodeMutex sync.Mutex + LogBuffers map[string]*strings.Builder // Separate log buffer for each node + StatusUpdateTick *time.Ticker + statusDone chan bool + initComplete bool // Flag to indicate initialization is complete +} + +// Logger is an io.Writer that writes to the UI log +type Logger struct { + UI *UI + ChainID string // Identify which chain this logger is for + mu sync.Mutex // Add mutex to protect concurrent writes +} + +// Write implements io.Writer +func (l *Logger) Write(p []byte) (n int, err error) { + l.mu.Lock() + defer l.mu.Unlock() + + // Always print to stdout as backup and for debugging + os.Stdout.Write(p) + + // Make sure we have a UI and app instance + if l.UI == nil { + // Fall back to stdout if UI is not available + return len(p), nil + } + + // Get the appropriate log buffer for this chain + logBuffer, exists := l.UI.LogBuffers[l.ChainID] + if !exists { + // If buffer doesn't exist, create one + logBuffer = &strings.Builder{} + l.UI.LogBuffers[l.ChainID] = logBuffer + } + + // Add the new content to the buffer + logBuffer.Write(p) + + // Keep buffer size reasonable (limit to 200,000 chars) + if logBuffer.Len() > 200000 { + // Get the current content and trim off the oldest entries + content := logBuffer.String() + trimmedContent := content[len(content)-190000:] + + // Find the first newline to start at a clean line + firstNewline := strings.Index(trimmedContent, "\n") + if firstNewline > 0 { + trimmedContent = trimmedContent[firstNewline+1:] + } + + // Reset the buffer with the trimmed content + logBuffer.Reset() + logBuffer.WriteString("...[older logs trimmed]...\n\n") + logBuffer.WriteString(trimmedContent) + } + + // Update the UI only if this is the currently displayed node + // and we're not in initialization + if l.ChainID == l.UI.DisplayedNode && l.UI.initComplete { + text := logBuffer.String() + l.updateFormattedLogDisplay(text) + } + + return len(p), nil +} + +// updateFormattedLogDisplay updates the log display with text +func (l *Logger) updateFormattedLogDisplay(text string) { + // For debugging - check if our log widget is nil + if l.UI.LogText == nil { + fmt.Println("ERROR: LogText widget is nil!") + return + } + + // Apply syntax highlighting to text + highlightedText := l.addLogSyntaxHinting(text) + + // Don't call DoFromGoroutine from the main thread + if fyne.CurrentApp() != nil && fyne.CurrentApp().Driver() != nil { + // Use asynchronous execution to avoid deadlocks + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + // Update the text + l.UI.LogText.SetText(highlightedText) + + // Scroll to the bottom + lineCount := len(strings.Split(highlightedText, "\n")) + if lineCount > 0 { + l.UI.LogText.CursorRow = lineCount - 1 + } + + // Refresh to ensure visible update + l.UI.LogText.Refresh() + + // Also refresh the window content to ensure updates are displayed + if l.UI.Window != nil && l.UI.Window.Content() != nil { + l.UI.Window.Content().Refresh() + } + }, false) // Use asynchronous execution to avoid deadlocks + } +} + +// addLogSyntaxHinting adds visual hints to make different parts of the log stand out +func (l *Logger) addLogSyntaxHinting(text string) string { + // Since we can't do true rich text with color, we'll use symbols to make certain lines stand out + lines := strings.Split(text, "\n") + for i, line := range lines { + // Add visual hints to different types of log lines + if strings.Contains(line, "=====") { + // Make headers stand out with stars + lines[i] = "★ " + line + " ★" + } else if strings.Contains(line, "ERROR") || + strings.Contains(line, "Error") || + strings.Contains(line, "error") || + strings.Contains(line, "failed") || + strings.Contains(line, "Failed") { + // Add error indicator + lines[i] = "❌ " + line + } else if strings.Contains(line, "WARN") || + strings.Contains(line, "Warning") || + strings.Contains(line, "warning") { + // Add warning indicator + lines[i] = "⚠️ " + line + } else if strings.Contains(line, "SUCCESS") || + strings.Contains(line, "successfully") || + strings.Contains(line, "Successfully") || + strings.Contains(line, "COMPLETED") { + // Add success indicator + lines[i] = "✅ " + line + } else if strings.Contains(line, "Downloading") || + strings.Contains(line, "Download progress") { + // Add download indicator + lines[i] = "⬇️ " + line + } + } + + return strings.Join(lines, "\n") +} + +// updateLogDisplay directly updates the log display with the given text +func (ui *UI) updateLogDisplay(text string) { + ui.updateUIInMainThread(func() { + // Apply syntax highlighting if possible + highlightedText := text + if logger, ok := ui.LogWriter.(*Logger); ok { + highlightedText = logger.addLogSyntaxHinting(text) + } + + ui.LogText.SetText(highlightedText) + lineCount := len(strings.Split(highlightedText, "\n")) + if lineCount > 0 { + ui.LogText.CursorRow = lineCount - 1 + } + ui.LogText.Refresh() + }) +} + +// NewUI creates a new UI instance +func NewUI() (*UI, error) { + // Create the Fyne application + fyneApp := app.New() + fyneApp.Settings().SetTheme(theme.DarkTheme()) + + // Create the main window + window := fyneApp.NewWindow("Cosmos Chain Registry") + window.Resize(fyne.NewSize(1200, 800)) + + // Set up the data directory + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get user home directory: %w", err) + } + dataDir := filepath.Join(homeDir, ".chain-registry-app") + + // Create the UI components + chainSelector := widget.NewSelect([]string{}, nil) + nodeSelector := widget.NewSelect([]string{"No running nodes"}, nil) + nodeSelector.Disable() // Disable until we have nodes running + + // Create a styled log text with light green text for better readability + logText := widget.NewMultiLineEntry() + logText.TextStyle = fyne.TextStyle{Monospace: true} + logText.Wrapping = fyne.TextWrapWord + logText.SetMinRowsVisible(25) + logText.Disable() // Use Disable instead of ReadOnly + + // Set a custom text color - use a brighter color than the default + // Note: We can't directly set text color on an Entry widget, but using monospace style helps visibility + + logScroll := container.NewScroll(logText) + // Increase the minimum size of the log scroll area + logScroll.SetMinSize(fyne.NewSize(1100, 600)) + + statusLabel := widget.NewLabel("Status: Not Running") + progressBar := widget.NewProgressBar() + startButton := widget.NewButton("Start Node", nil) + stopButton := widget.NewButton("Stop Node", nil) + stopButton.Disable() + + // Create the UI layout + headerContainer := container.New(layout.NewVBoxLayout(), + widget.NewLabel("Select Chain:"), + chainSelector, + ) + + nodeSelectionContainer := container.New(layout.NewVBoxLayout(), + widget.NewLabel("Running Nodes:"), + nodeSelector, + ) + + controlsContainer := container.New(layout.NewHBoxLayout(), + startButton, + stopButton, + statusLabel, + progressBar, + ) + + topContainer := container.New(layout.NewGridLayout(2), + headerContainer, + nodeSelectionContainer, + ) + + logContainer := container.New(layout.NewVBoxLayout(), + widget.NewLabel("Node Logs:"), + logScroll, + ) + + mainContainer := container.New(layout.NewVBoxLayout(), + topContainer, + controlsContainer, + logContainer, + ) + + window.SetContent(mainContainer) + + // Create the UI instance + ui := &UI{ + App: fyneApp, + Window: window, + ChainSelector: chainSelector, + NodeSelector: nodeSelector, + LogText: logText, + StartButton: startButton, + StopButton: stopButton, + StatusLabel: statusLabel, + ProgressBar: progressBar, + DataDir: dataDir, + ActiveNodes: make(map[string]*node.Node), + LogBuffers: make(map[string]*strings.Builder), + StatusUpdateTick: time.NewTicker(time.Second), + statusDone: make(chan bool), + initComplete: false, // Initialize to false + } + + // Set up the log writer + defaultLogger := &Logger{UI: ui} + ui.LogWriter = defaultLogger + + // Print initial message to stdout only (avoid UI updates during startup) + fmt.Println("===== CHAIN REGISTRY APP INITIALIZED =====") + fmt.Printf("Log system initialized. Logs will appear in the UI once loaded.\n") + fmt.Printf("Data directory: %s\n\n", dataDir) + + return ui, nil +} + +// SetupRegistry loads the chain registry data +func (ui *UI) SetupRegistry(registryPath string) error { + registry, err := chainregistry.NewRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + + ui.Registry = registry + + // Populate the chain selector + prettyNames := registry.GetPrettyNames() + if len(prettyNames) == 0 { + return fmt.Errorf("no chains found in registry") + } + + ui.ChainSelector.Options = prettyNames + ui.ChainSelector.SetSelected(prettyNames[0]) + + // Now that UI is set up, mark initialization as complete + ui.initComplete = true + + // Now it's safe to log to the UI + fmt.Fprintf(ui.LogWriter, "===== CHAIN REGISTRY APP INITIALIZED =====\n") + fmt.Fprintf(ui.LogWriter, "Loaded %d chains from registry\n", len(registry.GetLiveChains())) + fmt.Fprintf(ui.LogWriter, "Data directory: %s\n\n", ui.DataDir) + + return nil +} + +// SetupConnections sets up the UI signal connections +func (ui *UI) SetupConnections() { + // Chain selector change event + ui.ChainSelector.OnChanged = func(name string) { + chain, ok := ui.Registry.GetChainByPrettyName(name) + if !ok { + fmt.Fprintf(ui.LogWriter, "Chain not found: %s\n", name) + return + } + + fmt.Fprintf(ui.LogWriter, "Selected chain: %s (%s)\n", chain.PrettyName, chain.ChainName) + } + + // Node selector change event + ui.NodeSelector.OnChanged = func(name string) { + if name == "No running nodes" { + return + } + + // Update the displayed logs + ui.DisplayedNode = name + if logBuffer, ok := ui.LogBuffers[name]; ok { + ui.updateUIInMainThread(func() { + // Get log text and apply syntax highlighting + text := logBuffer.String() + if logger, ok := ui.LogWriter.(*Logger); ok { + text = logger.addLogSyntaxHinting(text) + } + + ui.LogText.SetText(text) + lineCount := len(strings.Split(text, "\n")) + if lineCount > 0 { + ui.LogText.CursorRow = lineCount - 1 + } + ui.LogText.Refresh() + }) + } + + // Update stop button state based on the selected node + ui.updateUIInMainThread(func() { + if _, ok := ui.ActiveNodes[name]; ok { + ui.StopButton.Enable() + } else { + ui.StopButton.Disable() + } + }) + } + + // Start button click event + ui.StartButton.OnTapped = func() { + go ui.startNode() + } + + // Stop button click event + ui.StopButton.OnTapped = func() { + go ui.stopNode() + } + + // Set up status update goroutine + go func() { + for { + select { + case <-ui.StatusUpdateTick.C: + ui.updateNodeStatus() + case <-ui.statusDone: + return + } + } + }() +} + +// updateNodeSelector updates the node selector dropdown with current running nodes +func (ui *UI) updateNodeSelector() { + ui.NodeMutex.Lock() + defer ui.NodeMutex.Unlock() + + // Create a list of running nodes + var nodeNames []string + + for name := range ui.ActiveNodes { + nodeNames = append(nodeNames, name) + } + + // Update the node selector + ui.updateUIInMainThread(func() { + if len(nodeNames) == 0 { + ui.NodeSelector.Options = []string{"No running nodes"} + ui.NodeSelector.SetSelected("No running nodes") + ui.NodeSelector.Disable() + } else { + ui.NodeSelector.Options = nodeNames + if ui.DisplayedNode == "" || !contains(nodeNames, ui.DisplayedNode) { + ui.DisplayedNode = nodeNames[0] + ui.NodeSelector.SetSelected(nodeNames[0]) + } else { + ui.NodeSelector.SetSelected(ui.DisplayedNode) + } + ui.NodeSelector.Enable() + } + }) +} + +// contains checks if a string slice contains a specific string +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +// getSDKVersion attempts to determine the Cosmos SDK version from chain info +func (ui *UI) getSDKVersion(chain *chainregistry.Chain) string { + // Check if we can determine it from the recommended version + if chain.Codebase.RecommendedVersion != "" { + // Many chains include SDK version info in compatible_versions + for _, ver := range chain.Codebase.CompatibleVersions { + if strings.Contains(ver, "sdk") { + return ver + } + } + } + + // Default to a recent version if we can't determine + return "0.46.0" // Assume newer by default for safety +} + +// needsMinGasPrice checks if this chain needs minimum gas prices set +func (ui *UI) needsMinGasPrice(chain *chainregistry.Chain) bool { + sdkVersion := ui.getSDKVersion(chain) + + // Extract version number if in format like "v0.45.0" or just "0.45.0" + version := sdkVersion + if strings.HasPrefix(version, "v") { + version = version[1:] + } + + // Get the major and minor version components + parts := strings.Split(version, ".") + if len(parts) >= 2 { + major := parts[0] + minor := parts[1] + + // Check if version is >= 0.45.0 + if major == "0" && (minor == "45" || minor == "46" || minor == "47" || minor == "48" || minor == "49" || minor >= "50") { + return true + } + + // Handle v1.x.x and above + if major >= "1" { + return true + } + } + + return false +} + +// updateUIInMainThread schedules a function to run on the main thread +func (ui *UI) updateUIInMainThread(updateFunc func()) { + // Use fyne's thread-safe mechanism to ensure UI updates happen on the main thread + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + updateFunc() + // Ensure the UI refreshes correctly + if ui.Window != nil && ui.Window.Content() != nil { + ui.Window.Content().Refresh() + } + }, true) // Use synchronous execution +} + +// startNode starts the currently selected node +func (ui *UI) startNode() { + ui.NodeMutex.Lock() + defer ui.NodeMutex.Unlock() + + // Get the selected chain + prettyName := ui.ChainSelector.Selected + chain, ok := ui.Registry.GetChainByPrettyName(prettyName) + if !ok { + fmt.Fprintf(ui.LogWriter, "Chain not found: %s\n", prettyName) + ui.updateUIInMainThread(func() { + ui.StatusLabel.SetText("Status: Error - Chain not found") + }) + return + } + + // Check if this node is already running + if _, exists := ui.ActiveNodes[chain.ChainName]; exists { + fmt.Fprintf(ui.LogWriter, "Node for %s is already running\n", chain.PrettyName) + return + } + + // Create a new log buffer for this chain + if _, exists := ui.LogBuffers[chain.ChainName]; !exists { + ui.LogBuffers[chain.ChainName] = &strings.Builder{} + } else { + // Clear existing log buffer + ui.LogBuffers[chain.ChainName].Reset() + } + + // Create a chain-specific logger + chainLogger := &Logger{ + UI: ui, + ChainID: chain.ChainName, + } + + // Update UI to show we're working on this chain + ui.DisplayedNode = chain.ChainName + ui.updateNodeSelector() + + // Add a prominent header to the chain's log + fmt.Fprintf(chainLogger, "\n========================================\n") + fmt.Fprintf(chainLogger, " STARTING NODE PROCESS\n") + fmt.Fprintf(chainLogger, "========================================\n\n") + + // Update the UI + ui.updateUIInMainThread(func() { + ui.StartButton.Disable() + ui.StopButton.Enable() + ui.ProgressBar.SetValue(0) + ui.StatusLabel.SetText(fmt.Sprintf("Status: Preparing %s", chain.PrettyName)) + }) + + fmt.Fprintf(chainLogger, "Selected chain: %s (%s)\n", chain.PrettyName, chain.ChainName) + fmt.Fprintf(chainLogger, "Chain ID: %s\n", chain.ChainID) + fmt.Fprintf(chainLogger, "Binary: %s\n", chain.DaemonName) + fmt.Fprintf(chainLogger, "Codebase: %s\n", chain.Codebase.GitRepo) + fmt.Fprintf(chainLogger, "Recommended version: %s\n\n", chain.Codebase.RecommendedVersion) + + // Create the node + nodeInstance, err := node.NewNode(chain, ui.DataDir, chainLogger) + if err != nil { + fmt.Fprintf(chainLogger, "Failed to create node: %v\n", err) + ui.updateUIInMainThread(func() { + ui.StartButton.Enable() + ui.StopButton.Disable() + ui.StatusLabel.SetText("Status: Error - Failed to create node") + }) + return + } + + // Download the binary + fmt.Fprintf(chainLogger, "======== BINARY ACQUISITION PHASE ========\n\n") + fmt.Fprintf(chainLogger, "Downloading binary for %s...\n", chain.PrettyName) + ui.updateUIInMainThread(func() { + ui.StatusLabel.SetText(fmt.Sprintf("Status: Downloading Binary for %s", chain.PrettyName)) + ui.ProgressBar.SetValue(0.1) + }) + + if err := nodeInstance.Download(); err != nil { + fmt.Fprintf(chainLogger, "\n===== ERROR =====\n") + fmt.Fprintf(chainLogger, "Failed to download binary: %v\n", err) + ui.updateUIInMainThread(func() { + ui.StartButton.Enable() + ui.StopButton.Disable() + ui.StatusLabel.SetText(fmt.Sprintf("Status: Error - Failed to download binary for %s", chain.PrettyName)) + }) + return + } + ui.updateUIInMainThread(func() { + ui.ProgressBar.SetValue(0.25) + ui.StatusLabel.SetText(fmt.Sprintf("Status: Initializing Node for %s", chain.PrettyName)) + }) + + // Initialize the node + fmt.Fprintf(chainLogger, "\n======== NODE INITIALIZATION PHASE ========\n\n") + fmt.Fprintf(chainLogger, "Initializing node for %s...\n", chain.PrettyName) + if err := nodeInstance.Initialize(); err != nil { + fmt.Fprintf(chainLogger, "\n===== ERROR =====\n") + fmt.Fprintf(chainLogger, "Failed to initialize node: %v\n", err) + ui.updateUIInMainThread(func() { + ui.StartButton.Enable() + ui.StopButton.Disable() + ui.StatusLabel.SetText(fmt.Sprintf("Status: Error - Failed to initialize %s", chain.PrettyName)) + }) + return + } + ui.updateUIInMainThread(func() { + ui.ProgressBar.SetValue(0.4) + }) + + // Set minimum gas prices if needed + if ui.needsMinGasPrice(chain) { + fmt.Fprintf(chainLogger, "\n======== SETTING MINIMUM GAS PRICES ========\n\n") + fmt.Fprintf(chainLogger, "Chain uses SDK >= 0.45.x, setting minimum gas prices...\n") + + if err := nodeInstance.SetMinGasPrices("0.0025stake"); err != nil { + fmt.Fprintf(chainLogger, "Warning: Failed to set minimum gas prices: %v\n", err) + // Continue anyway, this is not a critical error + } else { + fmt.Fprintf(chainLogger, "Minimum gas prices set successfully\n") + } + } + + ui.updateUIInMainThread(func() { + ui.ProgressBar.SetValue(0.5) + ui.StatusLabel.SetText(fmt.Sprintf("Status: Setting up State Sync for %s", chain.PrettyName)) + }) + + // Set up state sync + fmt.Fprintf(chainLogger, "\n======== STATE SYNC CONFIGURATION PHASE ========\n\n") + fmt.Fprintf(chainLogger, "Setting up state sync for %s...\n", chain.PrettyName) + if err := nodeInstance.SetupStateSync(); err != nil { + fmt.Fprintf(chainLogger, "\n===== ERROR =====\n") + fmt.Fprintf(chainLogger, "Failed to set up state sync: %v\n", err) + ui.updateUIInMainThread(func() { + ui.StartButton.Enable() + ui.StopButton.Disable() + ui.StatusLabel.SetText(fmt.Sprintf("Status: Error - Failed to set up state sync for %s", chain.PrettyName)) + }) + return + } + ui.updateUIInMainThread(func() { + ui.ProgressBar.SetValue(0.75) + ui.StatusLabel.SetText(fmt.Sprintf("Status: Starting Node for %s", chain.PrettyName)) + }) + + // Start the node + fmt.Fprintf(chainLogger, "\n======== NODE STARTUP PHASE ========\n\n") + fmt.Fprintf(chainLogger, "Starting node for %s...\n", chain.PrettyName) + if err := nodeInstance.Start(); err != nil { + fmt.Fprintf(chainLogger, "\n===== ERROR =====\n") + fmt.Fprintf(chainLogger, "Failed to start node: %v\n", err) + ui.updateUIInMainThread(func() { + ui.StartButton.Enable() + ui.StopButton.Disable() + ui.StatusLabel.SetText(fmt.Sprintf("Status: Error - Failed to start %s", chain.PrettyName)) + }) + return + } + + // Add to active nodes + ui.ActiveNodes[chain.ChainName] = nodeInstance + + // Update the node selector + ui.updateNodeSelector() + + ui.updateUIInMainThread(func() { + ui.ProgressBar.SetValue(1.0) + ui.StatusLabel.SetText(fmt.Sprintf("Status: Running %s", chain.PrettyName)) + ui.StartButton.Enable() // Re-enable to allow starting another node + }) + + fmt.Fprintf(chainLogger, "\n======== NODE STARTED SUCCESSFULLY ========\n") + fmt.Fprintf(chainLogger, "Node is now running for %s\n", chain.PrettyName) + fmt.Fprintf(chainLogger, "Chain ID: %s\n", chain.ChainID) + fmt.Fprintf(chainLogger, "Home directory: %s\n", nodeInstance.HomeDir) +} + +// stopNode stops the currently selected node +func (ui *UI) stopNode() { + ui.NodeMutex.Lock() + defer ui.NodeMutex.Unlock() + + // Use the selected node from the node selector + chainName := ui.DisplayedNode + if chainName == "" || chainName == "No running nodes" { + return + } + + activeNode, exists := ui.ActiveNodes[chainName] + if !exists || activeNode == nil { + return + } + + // Get the logger for this chain + var logger io.Writer + if _, ok := ui.LogBuffers[chainName]; ok { + logger = &Logger{ + UI: ui, + ChainID: chainName, + } + } else { + logger = ui.LogWriter // Fallback to default logger + } + + fmt.Fprintf(logger, "\n======== STOPPING NODE ========\n") + fmt.Fprintf(logger, "Stopping node...\n") + if err := activeNode.Stop(); err != nil { + fmt.Fprintf(logger, "Failed to stop node: %v\n", err) + } else { + fmt.Fprintf(logger, "Node stopped successfully\n") + + // Remove from active nodes + delete(ui.ActiveNodes, chainName) + + // Update the node selector + ui.updateNodeSelector() + } + + ui.updateUIInMainThread(func() { + // If there are no more active nodes, disable stop button + if len(ui.ActiveNodes) == 0 { + ui.StopButton.Disable() + } + ui.ProgressBar.SetValue(0) + }) +} + +// updateNodeStatus updates the node status in the UI +func (ui *UI) updateNodeStatus() { + ui.NodeMutex.Lock() + defer ui.NodeMutex.Unlock() + + // Update status for all running nodes + for chainName, activeNode := range ui.ActiveNodes { + status, err := activeNode.Status() + + // Check if the node is still running + if err != nil || status != "Running" { + // Node is no longer running, remove it from active nodes + fmt.Fprintf(&Logger{UI: ui, ChainID: chainName}, "Node status check: %s (chain: %s)\n", status, chainName) + + if status != "Running" { + fmt.Fprintf(&Logger{UI: ui, ChainID: chainName}, "Node is no longer running. Removing from active nodes.\n") + delete(ui.ActiveNodes, chainName) + } + } + } + + // Update the node selector after checking all nodes + if ui.DisplayedNode != "" && ui.DisplayedNode != "No running nodes" { + // If the displayed node is no longer active, update its status + if node, ok := ui.ActiveNodes[ui.DisplayedNode]; ok { + status, _ := node.Status() + ui.updateUIInMainThread(func() { + ui.StatusLabel.SetText(fmt.Sprintf("Status: %s - %s", status, ui.DisplayedNode)) + }) + } else { + // Node is no longer active + ui.updateUIInMainThread(func() { + ui.StatusLabel.SetText(fmt.Sprintf("Status: Not Running - %s", ui.DisplayedNode)) + }) + } + } else { + ui.updateUIInMainThread(func() { + ui.StatusLabel.SetText("Status: No Nodes Running") + }) + } + + // Update the node selector + ui.updateNodeSelector() +} + +// Start starts the UI +func (ui *UI) Start() { + // Set a more generous default window size to show logs clearly + ui.Window.Resize(fyne.NewSize(1200, 800)) + + // Make the text area larger + ui.LogText.SetMinRowsVisible(25) + + // Force refresh + ui.Window.Content().Refresh() + + // Start the UI + ui.Window.ShowAndRun() + + // Clean up when the window is closed + ui.StatusUpdateTick.Stop() + close(ui.statusDone) + + // Stop all running nodes + for _, node := range ui.ActiveNodes { + node.Stop() + } +}