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
-
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 0000000000..509c1f1432
Binary files /dev/null and b/icon.png differ
diff --git a/pkg/chainregistry/chainregistry.go b/pkg/chainregistry/chainregistry.go
new file mode 100644
index 0000000000..bba526b743
--- /dev/null
+++ b/pkg/chainregistry/chainregistry.go
@@ -0,0 +1,197 @@
+// Package chainregistry provides functionality to parse and work with Cosmos chain registry data
+package chainregistry
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// Chain represents the chain.json data structure
+type Chain struct {
+ ChainName string `json:"chain_name"`
+ Status string `json:"status"`
+ NetworkType string `json:"network_type"`
+ PrettyName string `json:"pretty_name"`
+ ChainID string `json:"chain_id"`
+ Bech32Prefix string `json:"bech32_prefix"`
+ DaemonName string `json:"daemon_name"`
+ NodeHome string `json:"node_home"`
+ Codebase Codebase `json:"codebase"`
+ Peers Peers `json:"peers"`
+ APIs APIs `json:"apis"`
+ Images []ChainImage `json:"images"`
+ LogoURIs LogoURIs `json:"logo_URIs"`
+ Description string `json:"description,omitempty"`
+}
+
+// Codebase represents the codebase section of the chain.json file
+type Codebase struct {
+ GitRepo string `json:"git_repo"`
+ RecommendedVersion string `json:"recommended_version"`
+ CompatibleVersions []string `json:"compatible_versions"`
+ Consensus CodebaseConsensus `json:"consensus,omitempty"`
+ Cosmwasm *CodebaseCosmwasm `json:"cosmwasm,omitempty"`
+ Binaries Binaries `json:"binaries,omitempty"`
+}
+
+// CodebaseConsensus represents the consensus section of the codebase
+type CodebaseConsensus struct {
+ Type string `json:"type"`
+ Version string `json:"version"`
+}
+
+// CodebaseCosmwasm represents the cosmwasm section of the codebase
+type CodebaseCosmwasm struct {
+ Enabled bool `json:"enabled"`
+}
+
+// Binaries represents the binaries section of the codebase
+type Binaries struct {
+ LinuxAmd64 string `json:"linux/amd64,omitempty"`
+ LinuxArm64 string `json:"linux/arm64,omitempty"`
+ DarwinAmd64 string `json:"darwin/amd64,omitempty"`
+ DarwinArm64 string `json:"darwin/arm64,omitempty"`
+}
+
+// Peers represents the peers section of the chain.json file
+type Peers struct {
+ Seeds []Peer `json:"seeds"`
+ PersistentPeers []Peer `json:"persistent_peers"`
+}
+
+// Peer represents a peer endpoint
+type Peer struct {
+ ID string `json:"id"`
+ Address string `json:"address"`
+ Provider string `json:"provider"`
+}
+
+// APIs represents the API endpoints for the chain
+type APIs struct {
+ RPC []API `json:"rpc"`
+ REST []API `json:"rest"`
+ GRPC []API `json:"grpc"`
+}
+
+// API represents an API endpoint
+type API struct {
+ Address string `json:"address"`
+ Provider string `json:"provider"`
+}
+
+// ChainImage represents an image for the chain
+type ChainImage struct {
+ PNG string `json:"png,omitempty"`
+ SVG string `json:"svg,omitempty"`
+ Theme ImageTheme `json:"theme,omitempty"`
+}
+
+// ImageTheme represents the theme for an image
+type ImageTheme struct {
+ PrimaryColorHex string `json:"primary_color_hex"`
+}
+
+// LogoURIs represents logo URIs for the chain
+type LogoURIs struct {
+ PNG string `json:"png,omitempty"`
+ SVG string `json:"svg,omitempty"`
+}
+
+// Registry represents the chain registry
+type Registry struct {
+ Chains map[string]*Chain
+}
+
+// NewRegistry creates a new instance of the chain registry
+func NewRegistry(registryPath string) (*Registry, error) {
+ registry := &Registry{
+ Chains: make(map[string]*Chain),
+ }
+
+ // Walk through the registry path to find all chain.json files
+ err := filepath.Walk(registryPath, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ // Skip directories and non-chain.json files
+ if info.IsDir() || info.Name() != "chain.json" {
+ return nil
+ }
+
+ // Skip test networks
+ if filepath.Base(filepath.Dir(filepath.Dir(path))) == "testnets" {
+ return nil
+ }
+
+ // Read and parse the chain.json file
+ chain, err := parseChainJSON(path)
+ if err != nil {
+ fmt.Printf("Warning: Failed to parse chain.json at %s: %v\n", path, err)
+ return nil
+ }
+
+ // Add the chain to the registry
+ registry.Chains[chain.ChainName] = chain
+ return nil
+ })
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to walk registry path: %w", err)
+ }
+
+ return registry, nil
+}
+
+// parseChainJSON parses a chain.json file into a Chain struct
+func parseChainJSON(path string) (*Chain, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read chain.json: %w", err)
+ }
+
+ var chain Chain
+ if err := json.Unmarshal(data, &chain); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal chain.json: %w", err)
+ }
+
+ return &chain, nil
+}
+
+// GetChain returns a chain by name
+func (r *Registry) GetChain(name string) (*Chain, bool) {
+ chain, ok := r.Chains[name]
+ return chain, ok
+}
+
+// GetLiveChains returns all chains with status "live"
+func (r *Registry) GetLiveChains() []*Chain {
+ var chains []*Chain
+ for _, chain := range r.Chains {
+ if chain.Status == "live" && chain.NetworkType == "mainnet" {
+ chains = append(chains, chain)
+ }
+ }
+ return chains
+}
+
+// GetPrettyNames returns all chain pretty names
+func (r *Registry) GetPrettyNames() []string {
+ var names []string
+ for _, chain := range r.GetLiveChains() {
+ names = append(names, chain.PrettyName)
+ }
+ return names
+}
+
+// GetChainByPrettyName returns a chain by its pretty name
+func (r *Registry) GetChainByPrettyName(prettyName string) (*Chain, bool) {
+ for _, chain := range r.Chains {
+ if chain.PrettyName == prettyName {
+ return chain, true
+ }
+ }
+ return nil, false
+}
diff --git a/pkg/node/node.go b/pkg/node/node.go
new file mode 100644
index 0000000000..0733c55bd3
--- /dev/null
+++ b/pkg/node/node.go
@@ -0,0 +1,822 @@
+// Package node provides functionality for managing Cosmos blockchain nodes
+package node
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/faddat/chain-registry/pkg/chainregistry"
+)
+
+// Node represents a Cosmos blockchain node
+type Node struct {
+ Chain *chainregistry.Chain
+ DataDir string
+ Binary string
+ BinaryPath string
+ HomeDir string
+ StateSyncRPC string
+ StateSyncPeers []string
+ LogWriter io.Writer
+ cmd *exec.Cmd
+}
+
+// NewNode creates a new Node instance for a given chain
+func NewNode(chain *chainregistry.Chain, dataDir string, logWriter io.Writer) (*Node, error) {
+ if chain == nil {
+ return nil, fmt.Errorf("chain cannot be nil")
+ }
+
+ // Determine the binary name
+ binary := chain.DaemonName
+ if binary == "" {
+ return nil, fmt.Errorf("daemon name not specified in chain.json")
+ }
+
+ // Set the data directory
+ if dataDir == "" {
+ 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 node directories
+ homeDir := filepath.Join(dataDir, chain.ChainName)
+ if err := os.MkdirAll(homeDir, 0755); err != nil {
+ return nil, fmt.Errorf("failed to create node home directory: %w", err)
+ }
+
+ // If the log writer is nil, use os.Stdout
+ if logWriter == nil {
+ logWriter = os.Stdout
+ }
+
+ node := &Node{
+ Chain: chain,
+ DataDir: dataDir,
+ Binary: binary,
+ BinaryPath: filepath.Join(dataDir, "bin", binary),
+ HomeDir: homeDir,
+ LogWriter: logWriter,
+ }
+
+ return node, nil
+}
+
+// Download downloads the binary for the chain
+func (n *Node) Download() error {
+ // Create the bin directory if it doesn't exist
+ binDir := filepath.Join(n.DataDir, "bin")
+ if err := os.MkdirAll(binDir, 0755); err != nil {
+ return fmt.Errorf("failed to create bin directory: %w", err)
+ }
+
+ // Check if the binary already exists
+ if _, err := os.Stat(n.BinaryPath); err == nil {
+ fmt.Fprintf(n.LogWriter, "\n===== BINARY ALREADY EXISTS =====\n")
+ fmt.Fprintf(n.LogWriter, "Path: %s\n", n.BinaryPath)
+ fmt.Fprintf(n.LogWriter, "Skipping download\n\n")
+ return nil
+ }
+
+ // Determine the URL for the binary based on the OS and architecture
+ var url string
+ switch runtime.GOOS {
+ case "linux":
+ if runtime.GOARCH == "amd64" {
+ url = n.Chain.Codebase.Binaries.LinuxAmd64
+ } else if runtime.GOARCH == "arm64" {
+ url = n.Chain.Codebase.Binaries.LinuxArm64
+ }
+ case "darwin":
+ if runtime.GOARCH == "amd64" {
+ url = n.Chain.Codebase.Binaries.DarwinAmd64
+ } else if runtime.GOARCH == "arm64" {
+ url = n.Chain.Codebase.Binaries.DarwinArm64
+ }
+ }
+
+ if url == "" {
+ fmt.Fprintf(n.LogWriter, "\n===== NO PRECOMPILED BINARY AVAILABLE =====\n")
+ fmt.Fprintf(n.LogWriter, "Platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
+ fmt.Fprintf(n.LogWriter, "Attempting to build from source...\n\n")
+ return n.CompileFromSource()
+ }
+
+ // Download the binary
+ fmt.Fprintf(n.LogWriter, "\n===== DOWNLOADING BINARY =====\n")
+ fmt.Fprintf(n.LogWriter, "Chain: %s\n", n.Chain.PrettyName)
+ fmt.Fprintf(n.LogWriter, "Source: %s\n", url)
+ fmt.Fprintf(n.LogWriter, "Destination: %s\n", n.BinaryPath)
+ fmt.Fprintf(n.LogWriter, "Starting download...\n\n")
+
+ // Make request with proper headers for redirect following
+ client := &http.Client{
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ return nil
+ },
+ }
+
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("User-Agent", "Chain-Registry-App/1.0")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to download binary: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to download binary, status code: %d", resp.StatusCode)
+ }
+
+ // Get Content-Length if available for progress reporting
+ contentLength := resp.ContentLength
+ if contentLength > 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()
+ }
+}