diff --git a/.gitignore b/.gitignore index 11db062d2f..e798801336 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ markets.json dist/ node_modules/ package-lock.json +client/cmd/dexc/dexc diff --git a/client/cmd/dexc/go.mod b/client/cmd/dexc/go.mod new file mode 100644 index 0000000000..7b10b20807 --- /dev/null +++ b/client/cmd/dexc/go.mod @@ -0,0 +1,27 @@ +module decred.org/dcrdex/client/cmd/dexc + +go 1.13 + +replace ( + decred.org/dcrdex => ../../../ + github.com/ltcsuite/ltcutil => github.com/ltcsuite/ltcutil v0.0.0-20190507133322-23cdfa9fcc3d +) + +require ( + decred.org/dcrdex v0.0.0-00010101000000-000000000000 + github.com/decred/dcrd/chaincfg/chainhash v1.0.2 + github.com/decred/dcrd/dcrutil/v2 v2.0.1 + github.com/decred/dcrd/gcs v1.1.0 + github.com/decred/dcrd/txscript/v2 v2.1.0 + github.com/decred/dcrd/wire v1.3.0 + github.com/decred/dcrwallet/errors/v2 v2.0.0 + github.com/decred/dcrwallet/p2p/v2 v2.0.0 + github.com/decred/dcrwallet/validate v1.1.1 + github.com/decred/dcrwallet/wallet/v3 v3.1.1-0.20191230143837-6a86dc4676f0 + github.com/decred/slog v1.0.0 + github.com/gdamore/tcell v1.3.0 + github.com/jessevdk/go-flags v1.4.0 + github.com/jrick/logrotate v1.0.0 + github.com/rivo/tcell v1.0.0 + github.com/rivo/tview v0.0.0-20191129065140-82b05c9fb329 +) diff --git a/client/cmd/dexc/go.sum b/client/cmd/dexc/go.sum new file mode 100644 index 0000000000..939050e02d --- /dev/null +++ b/client/cmd/dexc/go.sum @@ -0,0 +1,178 @@ +decred.org/cspp v0.2.0/go.mod h1:KVnB49sueBFCldRa/ivZCaWZbrPNEiXWwxHCf1jTYKI= +github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 h1:w1UutsfOrms1J05zt7ISrnJIXKzwaspym5BTKGx93EI= +github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/golangcrypto v0.0.0-20150304025918-53f62d9b43e8/go.mod h1:tYvUd8KLhm/oXvUeSEs2VlLghFjQt9+ZaF9ghH0JNjc= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/blake256 v1.0.0/go.mod h1:xXNWCE1jsAP8DAjP+rKw2MbeqLczjI3TRx2VK+9OEYY= +github.com/dchest/siphash v1.2.0/go.mod h1:q+IRvb2gOSrUnYoPqHiyHXS0FOBBOdl6tONBlVnOnt4= +github.com/dchest/siphash v1.2.1/go.mod h1:q+IRvb2gOSrUnYoPqHiyHXS0FOBBOdl6tONBlVnOnt4= +github.com/decred/base58 v1.0.0/go.mod h1:LLY1p5e3g91byL/UO1eiZaYd+uRoVRarybgcoymu9Ks= +github.com/decred/base58 v1.0.1 h1:w5qTcb0hYpKuIBYIn4Ckirkj1aOWrSq8onPQpb3eGg8= +github.com/decred/base58 v1.0.1/go.mod h1:H2ENcsJjye1G7CbRa67kV9OFaui0LGr56ntKKoY5g9c= +github.com/decred/dcrd/addrmgr v1.0.2/go.mod h1:gNnmTuf/Xkg8ZX3j5GXbajzPrSdf5bA7HitO2bjmq0Q= +github.com/decred/dcrd/blockchain/stake v1.0.1/go.mod h1:hgoGmWMIu2LLApBbcguVpzCEEfX7M2YhuMrQdpohJzc= +github.com/decred/dcrd/blockchain/stake/v2 v2.0.0/go.mod h1:jv/rKMcZ87lhvVkHot/tElxeAYEUJ3mnKPHJ7WPq86U= +github.com/decred/dcrd/blockchain/stake/v2 v2.0.2/go.mod h1:o2TT/l/YFdrt15waUdlZ3g90zfSwlA0WgQqHV9UGJF4= +github.com/decred/dcrd/blockchain/standalone v1.1.0/go.mod h1:6K8ZgzlWM1Kz2TwXbrtiAvfvIwfAmlzrtpA7CVPCUPE= +github.com/decred/dcrd/blockchain/v2 v2.1.0/go.mod h1:DBmX26fUDTQocIozF44Ydo5+m+QzaC6aMYMBFFsCOJs= +github.com/decred/dcrd/certgen v1.1.0/go.mod h1:ivkPLChfjdAgFh7ZQOtl6kJRqVkfrCq67dlq3AbZBQE= +github.com/decred/dcrd/chaincfg v1.1.1/go.mod h1:UlGtnp8Xx9YK+etBTybGjoFGoGXSw2bxZQuAnwfKv6I= +github.com/decred/dcrd/chaincfg v1.5.1 h1:u1Xbq0VTnAXIHW5ECqrWe0VYSgf5vWHqpSiwoLBzxAQ= +github.com/decred/dcrd/chaincfg v1.5.1/go.mod h1:FukMzTjkwzjPU+hK7CqDMQe3NMbSZAYU5PAcsx1wlv0= +github.com/decred/dcrd/chaincfg/chainhash v1.0.1/go.mod h1:OVfvaOsNLS/A1y4Eod0Ip/Lf8qga7VXCQjUQLbkY0Go= +github.com/decred/dcrd/chaincfg/chainhash v1.0.2 h1:rt5Vlq/jM3ZawwiacWjPa+smINyLRN07EO0cNBV6DGU= +github.com/decred/dcrd/chaincfg/chainhash v1.0.2/go.mod h1:BpbrGgrPTr3YJYRN3Bm+D9NuaFd+zGyNeIKgrhCXK60= +github.com/decred/dcrd/chaincfg/v2 v2.0.2/go.mod h1:hpKvhLCDAD/xDZ3V1Pqpv9fIKVYYi11DyxETguazyvg= +github.com/decred/dcrd/chaincfg/v2 v2.1.0/go.mod h1:hpKvhLCDAD/xDZ3V1Pqpv9fIKVYYi11DyxETguazyvg= +github.com/decred/dcrd/chaincfg/v2 v2.3.0 h1:ItmU+7DeUtyiabrcW+16MJFgY/BBeeYaPfkBLrFLyjo= +github.com/decred/dcrd/chaincfg/v2 v2.3.0/go.mod h1:7qUJTvn+y/kswSRZ4sT2+EmvlDTDyy2InvNFtX/hxk0= +github.com/decred/dcrd/connmgr/v2 v2.0.0/go.mod h1:HJ2q+m7DaMlNmQlY3WtbV3zETZfo4dfAi78z0ILLdqA= +github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/crypto/ripemd160 v1.0.0 h1:MciTnR4NfBqDFRFjFkrn8WPLP4Vo7t6ww6ghfn6wcXQ= +github.com/decred/dcrd/crypto/ripemd160 v1.0.0/go.mod h1:F0H8cjIuWTRoixr/LM3REB8obcWkmYx0gbxpQWR8RPg= +github.com/decred/dcrd/database v1.0.1/go.mod h1:ILCeyOHFew3fZ7K2B9jl+tp5qFOap/pEGoo6Yy6Wk0g= +github.com/decred/dcrd/database/v2 v2.0.0/go.mod h1:Sj2lvTRB0mfSu9uD7ObfwCY/eJ954GFU/X+AndJIyfE= +github.com/decred/dcrd/database/v2 v2.0.1/go.mod h1:ZOaWTv3IlNqCA+y7q3q5EozgmiDOmNwCSq3ntZn2CDo= +github.com/decred/dcrd/dcrec v0.0.0-20180721005212-59fe2b293f69/go.mod h1:cRAH1SNk8Mi9hKBc/DHbeiWz/fyO8KWZR3H7okrIuOA= +github.com/decred/dcrd/dcrec v0.0.0-20180721031028-5369a485acf6/go.mod h1:cRAH1SNk8Mi9hKBc/DHbeiWz/fyO8KWZR3H7okrIuOA= +github.com/decred/dcrd/dcrec v0.0.0-20180801202239-0761de129164/go.mod h1:cRAH1SNk8Mi9hKBc/DHbeiWz/fyO8KWZR3H7okrIuOA= +github.com/decred/dcrd/dcrec v1.0.0 h1:W+z6Es+Rai3MXYVoPAxYr5U1DGis0Co33scJ6uH2J6o= +github.com/decred/dcrd/dcrec v1.0.0/go.mod h1:HIaqbEJQ+PDzQcORxnqen5/V1FR3B4VpIfmePklt8Q8= +github.com/decred/dcrd/dcrec/edwards v0.0.0-20180721005212-59fe2b293f69/go.mod h1:+ehP0Hk/mesyZXttxCtBbhPX23BMpZJ1pcVBqUfbmvU= +github.com/decred/dcrd/dcrec/edwards v0.0.0-20180721031028-5369a485acf6/go.mod h1:+ehP0Hk/mesyZXttxCtBbhPX23BMpZJ1pcVBqUfbmvU= +github.com/decred/dcrd/dcrec/edwards v1.0.0 h1:UDcPNzclKiJlWqV3x1Fl8xMCJrolo4PB4X9t8LwKDWU= +github.com/decred/dcrd/dcrec/edwards v1.0.0/go.mod h1:HblVh1OfMt7xSxUL1ufjToaEvpbjpWvvTAUx4yem8BI= +github.com/decred/dcrd/dcrec/edwards/v2 v2.0.0 h1:E5KszxGgpjpmW8vN811G6rBAZg0/S/DftdGqN4FW5x4= +github.com/decred/dcrd/dcrec/edwards/v2 v2.0.0/go.mod h1:d0H8xGMWbiIQP7gN3v2rByWUcuZPm9YsgmnfoxgbINc= +github.com/decred/dcrd/dcrec/secp256k1 v1.0.0/go.mod h1:JPMFscGlgXTV684jxQNDijae2qrh0fLG7pJBimaYotE= +github.com/decred/dcrd/dcrec/secp256k1 v1.0.1/go.mod h1:lhu4eZFSfTJWUnR3CFRcpD+Vta0KUAqnhTsTksHXgy0= +github.com/decred/dcrd/dcrec/secp256k1 v1.0.2 h1:awk7sYJ4pGWmtkiGHFfctztJjHMKGLV8jctGQhAbKe0= +github.com/decred/dcrd/dcrec/secp256k1 v1.0.2/go.mod h1:CHTUIVfmDDd0KFVFpNX1pFVCBUegxW387nN0IGwNKR0= +github.com/decred/dcrd/dcrec/secp256k1/v2 v2.0.0 h1:3GIJYXQDAKpLEFriGFN8SbSffak10UXHGdIcFaMPykY= +github.com/decred/dcrd/dcrec/secp256k1/v2 v2.0.0/go.mod h1:3s92l0paYkZoIHuj4X93Teg/HB7eGM9x/zokGw+u4mY= +github.com/decred/dcrd/dcrjson/v3 v3.0.1/go.mod h1:fnTHev/ABGp8IxFudDhjGi9ghLiXRff1qZz/wvq12Mg= +github.com/decred/dcrd/dcrutil v1.1.1 h1:zOkGiumN/JkobhAgpG/zfFgUoolGKVGYT5na1hbYUoE= +github.com/decred/dcrd/dcrutil v1.1.1/go.mod h1:Jsttr0pEvzPAw+qay1kS1/PsbZYPyhluiNwwY6yBJS4= +github.com/decred/dcrd/dcrutil/v2 v2.0.0/go.mod h1:gUshVAXpd51DlcEhr51QfWL2HJGkMDM1U8chY+9VvQg= +github.com/decred/dcrd/dcrutil/v2 v2.0.1 h1:aL+c7o7Q66HV1gIif+XkNYo9DeorN3l01Vns8mh0mqs= +github.com/decred/dcrd/dcrutil/v2 v2.0.1/go.mod h1:JdEgF6eh0TTohPeiqDxqDSikTSvAczq0J7tFMyyeD+k= +github.com/decred/dcrd/gcs v1.0.2/go.mod h1:eLCvrzUsWro48TlTyrmFcZAZqnllYFz0vEv5VZtufF4= +github.com/decred/dcrd/gcs v1.1.0/go.mod h1:yBjhj217Vw5lw3aKnCdHip7fYb9zwMos8bCy5s79M9w= +github.com/decred/dcrd/gcs/v2 v2.0.0/go.mod h1:3XjKcrtvB+r2ezhIsyNCLk6dRnXRJVyYmsd1P3SkU3o= +github.com/decred/dcrd/hdkeychain/v2 v2.1.0/go.mod h1:DR+lD4uV8G0i3c9qnUJwjiGaaEWK+nSrbWCz1BRHBL8= +github.com/decred/dcrd/rpc/jsonrpc/types v1.0.1/go.mod h1:dJUp9PoyFYklzmlImpVkVLOr6j4zKuUv66YgemP2sd8= +github.com/decred/dcrd/rpc/jsonrpc/types/v2 v2.0.0/go.mod h1:c5S+PtQWNIA2aUakgrLhrlopkMadcOv51dWhCEdo49c= +github.com/decred/dcrd/rpcclient/v5 v5.0.0/go.mod h1:lg7e2kpulSpynHkS2JXJ+trQ4PWHaHLQcp/Q0eSIvBc= +github.com/decred/dcrd/txscript v1.0.1/go.mod h1:FqUX07Y+u3cJ1eIGPoyWbJg+Wk1NTllln/TyDpx9KnY= +github.com/decred/dcrd/txscript/v2 v2.0.0/go.mod h1:WStcyYYJa+PHJB4XjrLDRzV96/Z4thtsu8mZoVrU6C0= +github.com/decred/dcrd/txscript/v2 v2.1.0/go.mod h1:XaJAVrZU4NWRx4UEzTiDAs86op1m8GRJLz24SDBKOi0= +github.com/decred/dcrd/wire v1.1.0/go.mod h1:/JKOsLInOJu6InN+/zH5AyCq3YDIOW/EqcffvU8fJHM= +github.com/decred/dcrd/wire v1.2.0/go.mod h1:/JKOsLInOJu6InN+/zH5AyCq3YDIOW/EqcffvU8fJHM= +github.com/decred/dcrd/wire v1.3.0 h1:X76I2/a8esUmxXmFpJpAvXEi014IA4twgwcOBeIS8lE= +github.com/decred/dcrd/wire v1.3.0/go.mod h1:fnKGlUY2IBuqnpxx5dYRU5Oiq392OBqAuVjRVSkIoXM= +github.com/decred/dcrwallet/deployments/v2 v2.0.0/go.mod h1:fY1HV1vIeeY5bHjrMknUhB/ZOVIfthBiUlSgRqFFKrg= +github.com/decred/dcrwallet/errors/v2 v2.0.0/go.mod h1:2HYvtRuCE9XqDNCWhKmBuzLG364xUgcUIsJu02r0F5Q= +github.com/decred/dcrwallet/lru v1.0.0/go.mod h1:jEty7mdT5VaaV06DEV2Avv0R3HpGvUwvDW4lw8ECtiY= +github.com/decred/dcrwallet/p2p/v2 v2.0.0/go.mod h1:5/sskXRO69fGsuBcCekirXZCC/cZ5MwjNmj/wEPoLe0= +github.com/decred/dcrwallet/rpc/client/dcrd v1.0.0/go.mod h1:qrJri+p+cn+obQ8nkW5hTtagPcOnCqKPGBq1t02gBc0= +github.com/decred/dcrwallet/rpc/jsonrpc/types v1.3.0/go.mod h1:Xvekb43GtfMiRbyIY4ZJ9Uhd9HRIAcnp46f3q2eIExU= +github.com/decred/dcrwallet/rpc/jsonrpc/types v1.4.0/go.mod h1:Xvekb43GtfMiRbyIY4ZJ9Uhd9HRIAcnp46f3q2eIExU= +github.com/decred/dcrwallet/validate v1.1.1/go.mod h1:T++tlVcCOh2oSrEq4r5CKCvmftaQdq9uZwO7jSNYZaw= +github.com/decred/dcrwallet/version v1.0.1/go.mod h1:rXeMsUaI03WtlQrSol7Q7sJ8HBOB+tZvT7YQRXD5Y7M= +github.com/decred/dcrwallet/wallet/v3 v3.1.1-0.20191230143837-6a86dc4676f0/go.mod h1:SJ+++gtMdcUeqMv6iIO3gVGlGJfM+4iY2QSaAakhbUw= +github.com/decred/go-socks v1.1.0/go.mod h1:sDhHqkZH0X4JjSa02oYOGhcGHYp12FsY1jQ/meV8md0= +github.com/decred/slog v1.0.0 h1:Dl+W8O6/JH6n2xIFN2p3DNjCmjYwvrXsjlSJTQQ4MhE= +github.com/decred/slog v1.0.0/go.mod h1:zR98rEZHSnbZ4WHZtO0iqmSZjDLKhkXfrPTZQKtAonQ= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell v1.3.0 h1:r35w0JBADPZCVQijYebl6YMWWtHRqVEGt7kL2eBADRM= +github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= +github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs= +github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jrick/bitset v1.0.0/go.mod h1:ZOYB5Uvkla7wIEY4FEssPVi3IQXa02arznRaYaAEPe4= +github.com/jrick/logrotate v1.0.0 h1:lQ1bL/n9mBNeIXoTUoYRlK4dHuNJVofX9oWqBtPnSzI= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/jrick/wsrpc/v2 v2.0.0/go.mod h1:naH/fojac6vQWYgAA0e7b9TX/bShsWoVL7CwrdvFmUk= +github.com/jrick/wsrpc/v2 v2.2.0/go.mod h1:naH/fojac6vQWYgAA0e7b9TX/bShsWoVL7CwrdvFmUk= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/ltcsuite/ltcd v0.0.0-20190519120615-e27ee083f08f/go.mod h1:PPSOmqRCtob0mC9fTmLMlV2wfjPqEUNZFA/CiWxFC8w= +github.com/ltcsuite/ltcutil v0.0.0-20190507133322-23cdfa9fcc3d/go.mod h1:8Vg/LTOO0KYa/vlHWJ6XZAevPQThGH5sufO0Hrou/lA= +github.com/lucasb-eyer/go-colorful v1.0.2 h1:mCMFu6PgSozg9tDNMMK3g18oJBX7oYGrC09mS6CXfO4= +github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= +github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/rivo/tcell v1.0.0 h1:F96ly3DXu+1JBxBhq3lXWcSViRtKryhUOoe8LfA3K4c= +github.com/rivo/tcell v1.0.0/go.mod h1:HgVUF0Hx28QKFz0VWVJ0d2jZNOsZAgl4pcp15zNf2tM= +github.com/rivo/tview v0.0.0-20191129065140-82b05c9fb329 h1:MubHhHJ4mB0A5wMcc2am0/51RydztIDoumyOd0r0yBw= +github.com/rivo/tview v0.0.0-20191129065140-82b05c9fb329/go.mod h1:/rBeY22VG2QprWnEqG57IBC8biVu3i0DOIjRLc9I8H0= +github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk= +go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20180718160520-a2144134853f/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180808004115-f9ce57c11b24/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181212120007-b05ddf57801d/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191018095205-727590c5006e h1:ZtoklVMHQy6BFRHkbG6JzK+S6rX82//Yeok1vMlizfQ= +golang.org/x/sys v0.0.0-20191018095205-727590c5006e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/client/cmd/dexc/main.go b/client/cmd/dexc/main.go new file mode 100644 index 0000000000..b5cae81f4c --- /dev/null +++ b/client/cmd/dexc/main.go @@ -0,0 +1,90 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "sync" + + "decred.org/dcrdex/client/cmd/dexc/ui" + "decred.org/dcrdex/client/core" + "decred.org/dcrdex/client/rpcserver" + "decred.org/dcrdex/client/webserver" + "decred.org/dcrdex/dex" +) + +var log dex.Logger + +func main() { + appCtx, cancel := context.WithCancel(context.Background()) + // Catch ctrl+c. This will need to be smarter eventually, probably displaying + // a modal dialog to confirm closing, especially if servers are running or if + // swaps are in negotiation. + killChan := make(chan os.Signal, 1) + signal.Notify(killChan, os.Interrupt) + go func() { + <-killChan + cancel() + }() + + // Parse configuration and set up initial logging. + // + // DRAFT NOTE: It's a little odd that the Configure function is from the ui + // package. The ui.Config struct is used both here and in ui. Could create a + // types package used by both, but doing it this way works for now. + cfg, err := ui.Configure() + if err != nil { + fmt.Fprint(os.Stderr, "configration error: ", err) + return + } + + // If --notui is specified, don't create the tview application. Initialize + // logging with the standard stdout logger. + if cfg.NoTUI { + logStdout := func(msg []byte) { + os.Stdout.Write(msg) + } + clientCore := core.New(&core.Config{ + DBPath: cfg.DBPath, // global set in config.go + Logger: ui.NewLogger("CORE", nil), + Certs: cfg.Certs, + }) + go clientCore.Run(appCtx) + + ui.InitLogging(logStdout) + // At least one of --rpc or --web must be specified. + if !cfg.RPCOn && !cfg.WebOn { + fmt.Fprintf(os.Stderr, "Cannot run without TUI unless --rpc and/or --web is specified") + return + } + var wg sync.WaitGroup + if cfg.RPCOn { + wg.Add(1) + go func() { + defer wg.Done() + rpcserver.Run(appCtx, clientCore, cfg.RPCAddr, ui.NewLogger("RPC", logStdout)) + }() + } + if cfg.WebOn { + wg.Add(1) + go func() { + defer wg.Done() + webSrv, err := webserver.New(clientCore, cfg.WebAddr, ui.NewLogger("WEB", logStdout), cfg.ReloadHTML) + if err != nil { + log.Errorf("Error starting web server: %v", err) + return + } + webSrv.Run(appCtx) + }() + } + wg.Wait() + ui.Close() + return + } + // Run in TUI mode. + ui.Run(appCtx) +} diff --git a/client/cmd/dexc/ui/accountsview.go b/client/cmd/dexc/ui/accountsview.go new file mode 100644 index 0000000000..083a24291a --- /dev/null +++ b/client/cmd/dexc/ui/accountsview.go @@ -0,0 +1,135 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package ui + +import ( + "github.com/rivo/tview" +) + +var dummyFormBox = tview.NewBox() + +// accountsViewer is a view for manipulating DEX account and wallet settings. +type accountsViewer struct { + *tview.Flex + chain *focusChain + form *tview.Grid +} + +// newAccountsView is the constructor for an accountsViewer. +func newAccountsView() *accountsViewer { + // A journal for logging account-related messages. + acctsJournal := newJournal("Accounts Journal", nil) + // acctsLog is global. + acctsLog = NewLogger("ACCTS", acctsJournal.Write) + formBox := tview.NewGrid() + formBox.SetBackgroundColor(colorBlack) + var acctForm *tview.Form + + // The list of available DEX accounts, and a button to display a form to add + // a new DEX account. + acctsList := newChooser("Accounts", nil) + newAcctBttn := newSimpleButton("Add New DEX Account", func() { + formBox.Clear() + formBox.AddItem(acctForm, 0, 0, 1, 1, 0, 0, false) + acctForm.SetBorderColor(focusColor) + app.SetFocus(acctForm) + }) + acctsColumn := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(acctsList, 0, 1, false). + AddItem(newAcctBttn, 3, 0, false) + + // The list of exchange wallets registered, and a button to dipslay a form + // to add a new wallet. + walletsList := newChooser("Wallets", nil) + newWalletBttn := newSimpleButton("Add Wallet", func() { + acctsLog.Errorf("cannot add a wallet without an account") + }) + walletsColumn := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(walletsList, 0, 1, false). + AddItem(newWalletBttn, 3, 0, false) + + // The third column is the main content area (formBox), and a log display at + // the bottom + thirdColumn := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(fullyCentered(formBox, 60, 17), 0, 3, false). + AddItem(acctsJournal, 0, 1, false) + + // The main view. + wgt := tview.NewFlex(). + AddItem(acctsColumn, 30, 0, false). + AddItem(walletsColumn, 30, 0, false). + AddItem(thirdColumn, 0, 1, false) + + // A form to add a DEX account. + // + // DRAFT NOTE: I think we need more control over forms and should implement + // them using tview.Grid, but this is a start. A major downside is that + // tview.Form hijacks tab and arrow keys, making for some unintuitive + // navigation behavior. + var acctURL, dcrAcctName, dcrwAddr, dcrwPW, dcrwRPCUser, dcrwRPCPass string + acctForm = tview.NewForm(). + AddInputField("DEX URL", "", 0, nil, func(url string) { + acctURL = url + }). + AddInputField("Account Name", "", 0, nil, func(name string) { + dcrAcctName = name + }). + AddInputField("dcrwallet RPC Address", "", 0, nil, func(name string) { + dcrwAddr = name + }). + AddInputField("RPC Username", "", 0, nil, func(name string) { + dcrwRPCUser = name + }). + AddPasswordField("RPC Password", "", 0, 0, func(name string) { + dcrwRPCPass = name + }). + AddPasswordField("Wallet Password", "", 0, 0, func(pw string) { + dcrwPW = pw + }). + AddButton("register", func() { + // Obviously the password won't be echoed. Just this way for + // demonstration. + acctsLog.Infof("registering acct %s with password %s and wallet node %s for DEX %s, using RPC username %s and RPC password %s", + dcrAcctName, dcrwPW, dcrwAddr, acctURL, dcrwRPCUser, dcrwRPCPass) + }). + SetButtonsAlign(tview.AlignRight). + SetFieldBackgroundColor(metalBlue). + SetButtonBackgroundColor(metalBlue) + acctForm.SetCancelFunc(func() { + acctForm.SetBorderColor(blurColor) + acctsView.setForm(dummyFormBox) + setFocus(newAcctBttn) + }).SetBorder(true).SetBorderColor(blurColor) + acctForm.SetTitle("DEX Registration") + + av := &accountsViewer{ + Flex: wgt, + form: formBox, + } + av.chain = newFocusChain(av, acctsList, newAcctBttn, walletsList, newWalletBttn, acctsJournal) + return av +} + +// AddFocus is part of the focuser interface. Since the accountsViewer supports +// sub-focus, this method simply passes focus to the focus chain and sets the +// view's border color. +func (v *accountsViewer) AddFocus() { + // Pass control to the focusChain, but keep the border color on the view. + v.chain.focus() + v.SetBorderColor(focusColor) +} + +// RemoveFocus is part of the focuser interface. +func (v *accountsViewer) RemoveFocus() { + v.SetBorderColor(blurColor) +} + +// Set the currently displayed form. +func (v *accountsViewer) setForm(form tview.Primitive) { + v.form.Clear() + v.form.AddItem(form, 0, 0, 1, 1, 0, 0, false) +} diff --git a/client/cmd/dexc/ui/config.go b/client/cmd/dexc/ui/config.go new file mode 100644 index 0000000000..f87e7fb154 --- /dev/null +++ b/client/cmd/dexc/ui/config.go @@ -0,0 +1,211 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package ui + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "os/user" + "path/filepath" + "runtime" + "strings" + + "github.com/decred/dcrd/dcrutil/v2" + flags "github.com/jessevdk/go-flags" +) + +const ( + maxLogRolls = 16 + defaultRPCAddr = "localhost:5757" + defaultWebAddr = "localhost:5758" + configFilename = "dexc_mainnet.conf" +) + +var ( + applicationDirectory = dcrutil.AppDataDir("dexclient", false) + defaultConfigPath = filepath.Join(applicationDirectory, configFilename) + logFilename, netDirectory string + logDirectory string + cfg *Config +) + +// setNet sets the filepath for the network directory and some network specific +// files. It returns a suggested path for the database file. +func setNet(net string) string { + netDirectory = filepath.Join(applicationDirectory, net) + logDirectory = filepath.Join(netDirectory, "logs") + logFilename = filepath.Join(logDirectory, "dex.log") + err := os.MkdirAll(netDirectory, 0700) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create net directory: %v\n", err) + os.Exit(1) + } + err = os.MkdirAll(logDirectory, 0700) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create log directory: %v\n", err) + os.Exit(1) + } + return filepath.Join(netDirectory, "dexc.db") +} + +// Config is the configuration for the DEX client application. +type Config struct { + DataDir string `long:"dir" description:"Path to application directory"` + Config string `long:"config" description:"Path to an INI configuration file. default:[home]/dexc_mainnet.conf"` + DBPath string `long:"db" description:"Database filepath. Database will be created if it does not exist."` + CertsPath string `long:"certs" description:"Path to a JSON-formatted file linking DEX URL keys to TLS certificate filepaths."` + RPCOn bool `long:"rpc" description:"turn on the rpc server"` + RPCAddr string `long:"rpcaddr" description:"RPCServer listen address"` + WebOn bool `long:"web" description:"turn on the web server"` + WebAddr string `long:"webaddr" description:"HTTP server address"` + NoTUI bool `long:"notui" description:"disable the terminal-based user interface. must be used with --rpc or --web"` + Testnet bool `long:"testnet" description:"use testnet"` + Simnet bool `long:"simnet" description:"use simnet"` + ReloadHTML bool `long:"reload-html" description:"Reload the webserver's page template with every request. For development purposes."` + // Certs is not set by the client. It is parsed from the JSON file at the + // Certs path. + Certs map[string]string +} + +var defaultConfig = Config{ + DataDir: applicationDirectory, + Config: defaultConfigPath, + RPCAddr: defaultRPCAddr, + WebAddr: defaultWebAddr, +} + +// Configure processes the application configuration. +func Configure() (*Config, error) { + // Pre-parse the command line options to see if an alternative config file + // or the version flag was specified. Override any environment variables + // with parsed command line flags. + iniCfg := defaultConfig + preCfg := iniCfg + preParser := flags.NewParser(&preCfg, flags.HelpFlag|flags.PassDoubleDash) + _, flagerr := preParser.Parse() + + if flagerr != nil { + e, ok := flagerr.(*flags.Error) + if !ok || e.Type != flags.ErrHelp { + preParser.WriteHelp(os.Stderr) + } + if ok && e.Type == flags.ErrHelp { + preParser.WriteHelp(os.Stdout) + os.Exit(0) + } + return nil, flagerr + } + + // If the app directory has been changed, but the config file path hasn't, + // reform the config file path with the new directory. + if preCfg.DataDir != applicationDirectory && preCfg.Config == defaultConfigPath { + preCfg.Config = filepath.Join(preCfg.DataDir, configFilename) + } + cfgPath := cleanAndExpandPath(preCfg.Config) + + // Load additional config from file. + parser := flags.NewParser(&iniCfg, flags.Default) + err := flags.NewIniParser(parser).ParseFile(cfgPath) + if err != nil { + if _, ok := err.(*os.PathError); !ok { + fmt.Fprintln(os.Stderr, err) + parser.WriteHelp(os.Stderr) + return nil, err + } + // Missing file is not an error. + } + + // Parse command line options again to ensure they take precedence. + _, err = parser.Parse() + if err != nil { + if e, ok := err.(*flags.Error); !ok || e.Type != flags.ErrHelp { + parser.WriteHelp(os.Stderr) + } + return nil, err + } + + // Set the global *Config. + cfg = &iniCfg + + if cfg.Simnet && cfg.Testnet { + return nil, fmt.Errorf("simnet and testnet cannot both be specified") + } + var defaultDBPath string + switch { + case cfg.Testnet: + defaultDBPath = setNet("testnet") + case cfg.Simnet: + defaultDBPath = setNet("simnet") + default: + defaultDBPath = setNet("mainnet") + } + + if cfg.CertsPath != "" { + b, err := ioutil.ReadFile(cfg.CertsPath) + if err != nil { + return nil, fmt.Errorf("error reading certificates file: %v", err) + } + err = json.Unmarshal(b, cfg.Certs) + if err != nil { + return nil, fmt.Errorf("error parsing certificates file: %v", err) + } + } + + if cfg.DBPath == "" { + cfg.DBPath = defaultDBPath + } + + return cfg, nil +} + +// cleanAndExpandPath expands environment variables and leading ~ in the passed +// path, cleans the result, and returns it. +func cleanAndExpandPath(path string) string { + // NOTE: The os.ExpandEnv doesn't work with Windows cmd.exe-style + // %VARIABLE%, but the variables can still be expanded via POSIX-style + // $VARIABLE. + path = os.ExpandEnv(path) + + if !strings.HasPrefix(path, "~") { + return filepath.Clean(path) + } + + // Expand initial ~ to the current user's home directory, or ~otheruser to + // otheruser's home directory. On Windows, both forward and backward + // slashes can be used. + path = path[1:] + + var pathSeparators string + if runtime.GOOS == "windows" { + pathSeparators = string(os.PathSeparator) + "/" + } else { + pathSeparators = string(os.PathSeparator) + } + + userName := "" + if i := strings.IndexAny(path, pathSeparators); i != -1 { + userName = path[:i] + path = path[i:] + } + + homeDir := "" + var u *user.User + var err error + if userName == "" { + u, err = user.Current() + } else { + u, err = user.Lookup(userName) + } + if err == nil { + homeDir = u.HomeDir + } + // Fallback to CWD if user lookup fails or user has no home directory. + if homeDir == "" { + homeDir = "." + } + + return filepath.Join(homeDir, path) +} diff --git a/client/cmd/dexc/ui/config_test.go b/client/cmd/dexc/ui/config_test.go new file mode 100644 index 0000000000..6c2ad0e945 --- /dev/null +++ b/client/cmd/dexc/ui/config_test.go @@ -0,0 +1,80 @@ +package ui + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" +) + +func TestConfigure(t *testing.T) { + // Command line arguments + oldArgs := os.Args + cmd := oldArgs[0] + defer func() { os.Args = oldArgs }() + + // Prepare a temporary directory. + dir, _ := ioutil.TempDir("", "test") + defer os.RemoveAll(dir) + + createFile := func(path, contents string) { + err := ioutil.WriteFile(path, []byte(contents), 0600) + if err != nil { + t.Fatalf("error writing %s: %v", path, err) + } + } + + check := func(tag string, res bool) { + if !res { + t.Fatalf("%s comparison failed", tag) + } + } + + mainFP := filepath.Join(dir, "dexc_mainnet.conf") + createFile(mainFP, "webaddr=:9876") + + testFP := filepath.Join(dir, "dexc_testnet.conf") + createFile(testFP, "notui=1\ntestnet=1\nrpc=1") + + simFP := filepath.Join(dir, "dexc_simnet.conf") + createFile(simFP, "webaddr=:1234\nsimnet=1\nweb=1") + + // Check the mainnet configuration. + os.Args = []string{cmd, "--dir", dir, "--config", mainFP} + _, err := Configure() + if err != nil { + t.Fatalf("mainnet Configure error: %v", err) + } + check("mainnet notui", cfg.NoTUI == false) + check("mainnet testnet", cfg.Testnet == false) + check("mainnet simnet", cfg.Simnet == false) + check("mainnet rpc", cfg.RPCOn == false) + check("mainnet web", cfg.WebOn == false) + check("mainnet webaddr", cfg.WebAddr == ":9876") + + // Check the testnet configuration. + os.Args = []string{cmd, "--dir", dir, "--testnet", "--config", testFP} + _, err = Configure() + if err != nil { + t.Fatalf("simnet Configure error: %v", err) + } + check("testnet notui", cfg.NoTUI == true) + check("testnet testnet", cfg.Testnet == true) + check("testnet simnet", cfg.Simnet == false) + check("testnet rpc", cfg.RPCOn == true) + check("testnet web", cfg.WebOn == false) + check("testnet webaddr", cfg.WebAddr == defaultWebAddr) + + // Check the simnet configuration. + os.Args = []string{cmd, "--dir", dir, "--simnet", "--config", simFP} + _, err = Configure() + if err != nil { + t.Fatalf("simnet Configure error: %v", err) + } + check("simnet notui", cfg.NoTUI == false) + check("simnet testnet", cfg.Testnet == false) + check("simnet simnet", cfg.Simnet == true) + check("simnet rpc", cfg.RPCOn == false) + check("simnet web", cfg.WebOn == true) + check("simnet webaddr", cfg.WebAddr == ":1234") +} diff --git a/client/cmd/dexc/ui/depthchart.go b/client/cmd/dexc/ui/depthchart.go new file mode 100644 index 0000000000..ed5388f24b --- /dev/null +++ b/client/cmd/dexc/ui/depthchart.go @@ -0,0 +1,285 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package ui + +import ( + "fmt" + "math" + "sync" + + "github.com/gdamore/tcell" + "github.com/rivo/tview" +) + +var ( + chartColor = tcell.GetColor("#0c456b") +) + +type depthPoint struct { + x float64 + y float64 +} + +// depthChart is a simple character-based chart. Right now, the chart only +// works for a fixed chart size which cannot be changed. +type depthChart struct { + *tview.Box + focus bool + // The chart data is protected by a mutex since more than one thread could be + // accessing simultaneously. That is not the case for focus, which is only + // accessed during calls initiated by tview, so assumed to be sequenced. + mtx sync.RWMutex + seq uint64 + drawID uint64 + runeRows []string +} + +func newDepthChart() *depthChart { + dc := &depthChart{ + Box: tview.NewBox(), + seq: 1, + } + dc.SetBorder(true) + dc.runeRows = dc.calcRows() + return dc +} + +// Need to handle concurrency for chart updates. +func (c *depthChart) newScreenRows() []string { + c.mtx.Lock() + defer c.mtx.Unlock() + if !c.focus { + return nil + } + if c.seq == c.drawID { + // Nothing to redraw. + return nil + } + c.runeRows = c.calcRows() + c.seq = c.drawID + return c.runeRows +} + +// Draw hijacks the embedded Box method to draw on the screen. +func (c *depthChart) Draw(screen tcell.Screen) { + screenX, screenY, width, _ := c.GetRect() + rows := c.newScreenRows() + for y, row := range rows { + tview.Print(screen, row, 0+screenX, y+screenY, width, tview.AlignLeft, chartColor) + } +} + +// refresh recalculates the chart rune rows. +// +// DRAFT NOTE: This method is currently just a demo of charting. It will need to +// be a lot smarter when real data is plotted. +func (c *depthChart) calcRows() []string { + rotations := float64(2.5) + numPts := 400 + amp := float64(5) + pts := make([]depthPoint, 0, numPts) + if numPts < 2 { + return nil + } + for i := 0; i < numPts; i++ { + x := float64(i) / float64(numPts) * rotations * 2 * math.Pi + pts = append(pts, depthPoint{ + x: float64(i), + y: math.Sin(x) * amp, + }) + } + width, height := marketChartWidth, marketChartHeight + if width == 0 || height == 0 { + return nil + } + runeRows := make([]string, 0, height) + edge, err := interpolate(pts, width, height) + if err != nil { + // Don't update the UI from a Draw method, so we won't print an error here. + // TODO: Add a file-only logger for logging these errors? + app.QueueUpdate(func() { + log.Errorf("interpolate error: %v", err) + }) + } + + for iy := 0; iy < height; iy++ { + y := height - iy - 1 + row := make([]rune, 0, width) + for _, pt := range edge { + switch { + case pt.y < y: + row = append(row, uniSpace) + case pt.y == y: + // fmt.Printf("-- drawing at %d\n", y) + row = append(row, pt.ch) + default: + row = append(row, fullBlock) + } + } + runeRows = append(runeRows, string(row)) + } + return runeRows +} + +// Focus is to the tview.Box Focus method, wrapped here to force a redraw of +// the chart. +func (c *depthChart) Focus(delegate func(p tview.Primitive)) { + c.focus = true + // Have to increment the seqID on focus to force redraw. + c.mtx.Lock() + c.seq++ + c.mtx.Unlock() + // Pass the delegate to the embedded Box's method. + c.Box.Focus(delegate) +} + +// Blur is to the tview.Box Focus method. Used to set the focus field. +func (c *depthChart) Blur() { + c.focus = false + c.Box.Blur() +} + +// See the unicode block elements at +// https://en.wikipedia.org/wiki/Box-drawing_character#Unicode +const ( + uniSpace rune = '\u0020' + bottomEight rune = '\u2581' + bottomQuarter rune = '\u2582' + bottomThreeEights rune = '\u2583' + bottomHalf rune = '\u2584' + bottomFiveEights rune = '\u2585' + bottomTwoThirds rune = '\u2586' + bottomSevenEights rune = '\u2587' + fullBlock rune = '\u2588' + // The remaining runes are not used right now, but they may be handy for + // additional sub-character detail in the future. + // bottomRightHeavy rune = '\u259f' + // bottomRightLite rune = '\u2597' + // bottomLeftHeavy rune = '\u2599' + // bottomLeftLite rune = '\u2596' + // topRightHeavy rune = '\u259c' + // topRightLite rune = '\u259d' + // topLeftHeavy rune = '\u259b' + // topLeftLite rune = '\u2598' + // shadedBlock rune = '\u2593' +) + +// edgePoint is a point on the console grid, composed of a character and a row +// number. +type edgePoint struct { + y int + ch rune +} + +// Translate the x,y chart data into a width edgePoints, each edgePoint with +// 0 < y < height, and an appropriate character. +// +// The algorithm projects the line graph onto the width x height grid, and finds +// the points where the charted line intersects the cell edges. The value +// assigned to a column is the average of the intersects of the left and +// right sides. +func interpolate(pts []depthPoint, width, height int) ([]edgePoint, error) { + xMin, xMax, yMin, yMax, err := readLimits(pts) + if err != nil { + return nil, err + } + numPts := len(pts) + yRange := float64(yMax - yMin) + xRange := float64(xMax - xMin) + yScaler, xScaler := yRange/float64(height), xRange/float64(width) + lastY := float64(pts[0].y) / yScaler + intersections := append(make([]float64, 0, numPts+1), lastY-yMin/yScaler) + lastX := int(float64(pts[0].x) / xScaler) + for i := 0; i < len(pts)-1; i++ { + left, right := pts[i], pts[i+1] + y0 := (left.y - yMin) / yScaler + y1 := (right.y - yMin) / yScaler + x0 := (left.x - xMin) / xScaler + x1 := (right.x - xMin) / xScaler + rightIdx := int(x1) + if rightIdx > lastX { + for xIdx := lastX + 1; xIdx <= rightIdx; xIdx++ { + x := float64(xIdx) + intersections = append(intersections, (x-x0)/(x1-x0)*(y1-y0)+y0) + } + } + lastX = rightIdx + } + if len(intersections) < width+1 { + if len(intersections) != width { + return nil, fmt.Errorf("expected %d or %d points after interpolation, got %d", + numPts, numPts+1, len(intersections)) + } + intersections = append(intersections, float64(pts[len(pts)-1].y-yMin)/yScaler) + } + return roughEdge(intersections) +} + +// Rough edge translates the intersections into 1 of 9 characters of appropriate +// character height. +func roughEdge(intersections []float64) ([]edgePoint, error) { + edge := make([]edgePoint, 0, len(intersections)-1) + for i := 0; i < len(intersections)-1; i++ { + left, right := intersections[i], intersections[i+1] + yFlt, partial := math.Modf((left + right) / 2) + var block rune + sixteenths := int(partial * 16) + switch sixteenths { + case 0: + block = uniSpace + case 1, 2: + block = bottomEight + case 3, 4: + block = bottomQuarter + case 5, 6: + block = bottomThreeEights + case 7, 8: + block = bottomHalf + case 9, 10: + block = bottomFiveEights + case 11, 12: + block = bottomTwoThirds + case 13, 14: + block = bottomSevenEights + default: + block = fullBlock + } + edge = append(edge, edgePoint{ + y: int(yFlt), + ch: block, + }) + } + return edge, nil +} + +// readLimits reads the data, performs a couple of basic checks, and extracts +// the data extents. +func readLimits(pts []depthPoint) (float64, float64, float64, float64, error) { + if len(pts) == 0 { + return 0, 0, 0, 0, fmt.Errorf("no points") + } + var yMin float64 = math.MaxFloat64 + var yMax float64 = -math.MaxFloat64 + xMin, xMax := yMin, yMax + lastX := pts[0].x + for _, pt := range pts { + x, y := pt.x, pt.y + if x < lastX { + return 0, 0, 0, 0, fmt.Errorf("non-increasing x not allowed") + } + if y > yMax { + yMax = y + } + if y < yMin { + yMin = y + } + if x > xMax { + xMax = x + } + if x < xMin { + xMin = x + } + } + return xMin, xMax, yMin, yMax, nil +} diff --git a/client/cmd/dexc/ui/journal.go b/client/cmd/dexc/ui/journal.go new file mode 100644 index 0000000000..c97bacfa46 --- /dev/null +++ b/client/cmd/dexc/ui/journal.go @@ -0,0 +1,85 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package ui + +import ( + "sync" + + "github.com/rivo/tview" +) + +var ( + maxJournalLines = 150 + journalTrimSize = 50 +) + +// A journal is a TextView with concurrency protection and buffer maintenance. +// It can be passed directly as an io.Writer for logging purposes. +type journal struct { + *tview.TextView + history [][]byte + mtx sync.Mutex +} + +// newJournal is a constructor for a *journal. +func newJournal(title string, keyFunc inputCapture) *journal { + txtView := tview.NewTextView(). + SetScrollable(true). + SetWordWrap(true). + SetDynamicColors(true) + txtView.SetBorderColor(blurColor). + SetInputCapture(keyFunc). + SetBorder(true). + SetTitle(title). + SetBorderPadding(1, 3, 1, 3) + + j := &journal{ + TextView: txtView, + history: make([][]byte, 0), + } + j.SetChangedFunc(func() { + if j.HasFocus() { + app.Draw() + } + }) + return j +} + +// The TextView's performance begins to lag after too many entries are added. +// Maintain a buffer that drops some log messages off of the front when the +// it gets too long. +func (j *journal) Write(p []byte) { + j.mtx.Lock() + defer j.mtx.Unlock() + j.history = append(j.history, p) + var err error + if len(j.history) >= maxJournalLines { + j.history = j.history[journalTrimSize:] + j.Clear() + for _, entry := range j.history { + _, err = j.TextView.Write(entry) + if err != nil { + break + } + } + } else { + _, err = j.TextView.Write(p) + } + if err != nil { + log.Errorf("error writing to the journal: %v", err) + } +} + +// AddFocus is part of the focuser interface, and will be called when this +// element receives focus. +func (j *journal) AddFocus() { + j.SetBorderColor(focusColor) + app.SetFocus(j) +} + +// RemoveFocus is part of the focuser interface, and will be called when this +// element loses focus. +func (j *journal) RemoveFocus() { + j.SetBorderColor(blurColor) +} diff --git a/client/cmd/dexc/ui/journal_test.go b/client/cmd/dexc/ui/journal_test.go new file mode 100644 index 0000000000..68c5e1b362 --- /dev/null +++ b/client/cmd/dexc/ui/journal_test.go @@ -0,0 +1,36 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package ui + +import ( + "math/rand" + "testing" + + "github.com/gdamore/tcell" +) + +func randByteString() []byte { + length := rand.Intn(150) + 10 + b := make([]byte, length) + rand.Read(b) + return b +} + +func TestJournal(t *testing.T) { + // journalTrimSize + // maxJournalLines + j := newJournal("test", func(event *tcell.EventKey) *tcell.EventKey { return nil }) + for i := 0; i < maxJournalLines-1; i++ { + j.Write(randByteString()) + } + if len(j.history) != maxJournalLines-1 { + t.Fatalf("wrong journal line count before trim. Expected %d, got %d", len(j.history), maxJournalLines-1) + } + // Writing one more line should for a trim. + j.Write(randByteString()) + if len(j.history) != maxJournalLines-journalTrimSize { + t.Fatalf("wrong journal line count after trim. Expected %d, got %d", len(j.history), maxJournalLines-journalTrimSize) + } + +} diff --git a/client/cmd/dexc/ui/log.go b/client/cmd/dexc/ui/log.go new file mode 100644 index 0000000000..bcf8580662 --- /dev/null +++ b/client/cmd/dexc/ui/log.go @@ -0,0 +1,70 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package ui + +import ( + "fmt" + "os" + + "github.com/decred/slog" + "github.com/jrick/logrotate/rotator" +) + +var ( + // logRotator is one of the logging outputs. It should be closed on + // application shutdown. + logRotator *rotator.Rotator + log slog.Logger + masterLogger = func([]byte) {} +) + +// logWriter implements an io.Writer that outputs to three separate +// destinations, a master logger (app journal), a custom logger (view +// journals), and a rotating log file. +type logWriter struct { + f func([]byte) +} + +// Write writes the data in p to all three destinations. +func (w logWriter) Write(p []byte) (n int, err error) { + w.f(p) + masterLogger(p) + return logRotator.Write(p) +} + +// InitLogging initializes the logging rotater to write logs to logFile and +// create roll files in the same directory. All output will also be provided to +// the provided function. It must be called before the package-global log +// rotator variables are used. +func InitLogging(masterLog func([]byte)) { + err := os.MkdirAll(logDirectory, 0700) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create log directory: %v\n", err) + os.Exit(1) + } + logRotator, err = rotator.New(logFilename, 32*1024, false, maxLogRolls) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create file rotator: %v\n", err) + os.Exit(1) + } + masterLogger = masterLog + log = NewLogger("APP", nil) +} + +// NewLogger creates a new logger that writes to the central rotating log file +// and also the provided function. +func NewLogger(tag string, f func(p []byte)) slog.Logger { + if f == nil { + f = func([]byte) {} + } + backendLog := slog.NewBackend(logWriter{f: f}) + return backendLog.Logger(tag) +} + +// Close closes the log rotator. +func Close() { + if logRotator != nil { + logRotator.Close() + } +} diff --git a/client/cmd/dexc/ui/marketview.go b/client/cmd/dexc/ui/marketview.go new file mode 100644 index 0000000000..85f7b57ccf --- /dev/null +++ b/client/cmd/dexc/ui/marketview.go @@ -0,0 +1,99 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package ui + +import ( + "github.com/rivo/tview" +) + +var ( + marketChartWidth = 90 + marketChartHeight = 10 +) + +// marketViewer is the market view, which includes the ability to view market +// info and place orders. +type marketViewer struct { + *tview.Flex + window *marketWindow + chain *focusChain +} + +func newMarketView() *marketViewer { + marketJournal := newJournal("Market Journal", nil) + marketLog = NewLogger("MRKT", marketJournal.Write) + marketList := newChooser("Markets", nil) + markets := clientCore.ListMarkets() + for _, mktInfo := range markets { + for _, market := range mktInfo.Markets { + m := market + marketList.addEntry(m.Display(), func() { + marketLog.Infof("%s selected", m.Display()) + }) + } + + } + + // the marketWindow is the main window on the market view. + marketWindow := newMarketWindow() + + // The marketColumn holds the marketWindow and the marketJournal. + marketColumn := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(marketWindow, 0, 3, false). + AddItem(marketJournal, 0, 1, false) + + wgt := tview.NewFlex(). + AddItem(marketList, 15, 0, false). + AddItem(marketColumn, 0, 1, false) + wgt.SetBorder(true).SetBorderColor(blurColor) + + mv := &marketViewer{ + Flex: wgt, + window: marketWindow, + } + mv.chain = newFocusChain(mv, marketList, marketJournal) + + return mv +} + +func (v *marketViewer) AddFocus() { + // Pass control to the focusChain when the view receives focus. + v.chain.focus() + v.window.chart.Focus(nil) + v.SetBorderColor(focusColor) +} + +func (v *marketViewer) RemoveFocus() { + v.window.chart.Blur() + v.SetBorderColor(blurColor) +} + +// marketWindow is the market view main window layout manager. +type marketWindow struct { + *tview.Flex + chart *depthChart +} + +func newMarketWindow() *marketWindow { + chart := newDepthChart() + chart.SetBorderPadding(1, 1, 1, 1) + chartBox := tview.NewFlex(). + AddItem(chart, 0, 1, false) + chartBox.SetBorder(true).SetBorderColor(colorBlack).SetTitle("A Chart") + wgt := tview.NewFlex().AddItem(fullyCentered(chartBox, marketChartWidth, marketChartHeight+2), 0, 1, false) + wgt.SetBorder(true).SetBorderColor(colorBlack) + return &marketWindow{ + Flex: wgt, + chart: chart, + } +} + +func (w *marketWindow) AddFocus() { + w.SetBorderColor(focusColor) +} + +func (w *marketWindow) RemoveFocus() { + w.SetBorderColor(blurColor) +} diff --git a/client/cmd/dexc/ui/serverview.go b/client/cmd/dexc/ui/serverview.go new file mode 100644 index 0000000000..c6f58f9521 --- /dev/null +++ b/client/cmd/dexc/ui/serverview.go @@ -0,0 +1,130 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package ui + +import ( + "context" + "strings" + + "github.com/decred/slog" + "github.com/gdamore/tcell" + "github.com/rivo/tview" +) + +// serverView is a view with a simple for for starting a server and a journal +// to view its log output. +type serverView struct { + *tview.Flex + form *tview.Form + toggle func() +} + +// newServerView is a constructor for the server view. The server view's only +// job is to run the supplied runFunc and display its log output. +func newServerView(tag, addr string, runFunc func(context.Context, string, slog.Logger)) *serverView { + // A journal to display log output from the server. + var serverJournal *journal + serverJournal = newJournal(tag+" Journal", func(e *tcell.EventKey) *tcell.EventKey { + switch e.Key() { + case tcell.KeyEscape: + webView.RemoveFocus() + serverJournal.SetBorderColor(blurColor) + setFocus(mainMenu) + } + return e + }) + + // Get a logger for the server. + serverLogger := NewLogger(strings.ToUpper(tag)+"SVR", func(p []byte) { + serverJournal.Write(p) + }) + + onMsg := " " + tag + " server is on" + offMsg := " " + tag + " server is off" + + // The indicator is just a small box that changes color when the server is on. + indicator := tview.NewBox(). + SetBackgroundColor(offColor) + lbl := tview.NewTextView().SetText(offMsg) + header := tview.NewFlex(). + AddItem(verticallyCentered(indicator, 1), 2, 0, false). + AddItem(lbl, 0, 1, false) + + // The arguments necessary to add the address input to the form. + addrInput := func() (string, string, int, func(string, rune) bool, func(string)) { + return "address", addr, 0, tview.InputFieldInteger, func(v string) { + addr = v + } + } + + // Crate a method to toggle the state of the server. Will be available as + // (*serverView).toggle. + var form *tview.Form + var serverToggle func() + var ctx context.Context + var kill func() + serverToggle = func() { + ctx, kill = context.WithCancel(appCtx) + form.Clear(true) + form.AddButton("stop", func() { + kill() + }) + setFocus(serverJournal) + indicator.SetBackgroundColor(onColor) + lbl.SetText(onMsg) + go func() { + runFunc(ctx, addr, serverLogger) + app.QueueUpdateDraw(func() { + indicator.SetBackgroundColor(offColor) + form.ClearButtons() + form.AddInputField(addrInput()) + form.AddButton("start", serverToggle) + setFocus(serverJournal) + lbl.SetText(offMsg) + }) + }() + } + + // The form to accept the address and start the server, and then present a + // button to stop the server. + form = tview.NewForm(). + AddInputField(addrInput()). + AddButton("start", serverToggle). + SetButtonsAlign(tview.AlignRight). + SetFieldBackgroundColor(metalBlue). + SetButtonBackgroundColor(metalBlue) + form.SetBorderColor(blurColor).SetBorder(true) + form.SetCancelFunc(func() { + form.SetBorderColor(blurColor) + serverJournal.AddFocus() + }) + + // formBox adds a header and centers the form. + formBox := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(header, 2, 0, false). + AddItem(form, 0, 1, false) + + wgt := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(fullyCentered(formBox, 40, 10), 0, 1, false). + AddItem(serverJournal, 0, 2, false) + wgt.SetBorder(true) + return &serverView{ + Flex: wgt, + form: form, + toggle: serverToggle, + } +} + +func (w *serverView) AddFocus() { + w.SetBorderColor(focusColor) + w.form.SetBorderColor(focusColor) + w.form.SetFocus(1) + app.SetFocus(w.form) +} + +func (w *serverView) RemoveFocus() { + w.SetBorderColor(blurColor) +} diff --git a/client/cmd/dexc/ui/widgets.go b/client/cmd/dexc/ui/widgets.go new file mode 100644 index 0000000000..07856c9f6f --- /dev/null +++ b/client/cmd/dexc/ui/widgets.go @@ -0,0 +1,480 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package ui + +import ( + "context" + "fmt" + "strings" + "sync/atomic" + + "decred.org/dcrdex/client/core" + "decred.org/dcrdex/client/rpcserver" + "decred.org/dcrdex/client/webserver" + "github.com/decred/slog" + "github.com/gdamore/tcell" + "github.com/rivo/tview" +) + +var ( + // The application context. Provided to the Run function, but stored globally. + appCtx context.Context + // The tview application. + app *tview.Application + // The core DEX client application. Used by both the RPC server and the + // web server. + clientCore *core.Core + // These are the main view widgets and loggers attached to their journals. + screen *Screen + mainMenu *chooser + appJournal *journal + webView *serverView + rpcView *serverView + marketView *marketViewer + acctsView *accountsViewer + noteJournal *journal + noteLog slog.Logger + marketLog slog.Logger + acctsLog slog.Logger + focusColor = tcell.GetColor("#dedeff") + blurColor = tcell.GetColor("grey") + onColor = tcell.GetColor("green") + metalBlue = tcell.GetColor("#072938") + backgroundColor = tcell.GetColor("#3f3f3f") + offColor = blurColor + colorBlack = tcell.GetColor("black") + notificationCount uint32 +) + +// For brevity, a commonly used tview callback. +type inputCapture func(event *tcell.EventKey) *tcell.EventKey + +// Run the TUI app. +func Run(ctx context.Context) { + appCtx = ctx + // Initialize logging to a widget + appJournal = newJournal("Application Log", handleAppLogKey) + InitLogging(func(p []byte) { + appJournal.Write(p) + }) + // Close closes the log rotator. + defer Close() + // Create the UI and start the app. + createApp() + if err := app.SetRoot(screen, true).SetFocus(mainMenu).Run(); err != nil { + panic(err) + } +} + +// A focuser is satisfied by anything that embeds *tview.Box and implements +// AddFocus and RemoveFocus methods. The two additional methods are not from +// tview, and are used to help with focus control and chaining +type focuser interface { + tview.Primitive + SetInputCapture(capture func(event *tcell.EventKey) *tcell.EventKey) *tview.Box + GetInputCapture() func(event *tcell.EventKey) *tcell.EventKey + // AddFocus and RemoveFocus enable additional control over of focus + // navigation. + AddFocus() + RemoveFocus() +} + +// Screen is the the full screen. It is separated into a permanent menu on the +// left, and a swappable view on the right. +type Screen struct { + *tview.Flex + right focuser + focused focuser +} + +var welcomeMessage = "Welcome to Decred DEX. Use [#838ac7]Up[white] and " + + "[#838ac7]Down[white] arrows and then press [#838ac7]Enter[white] to select" + + " a new view. The [#838ac7]Escape[white] key will usually toggle the focus" + + " between the menu and the currently selected view. When the selected view" + + " is focused, most navigation can be done with your the [#838ac7]Left" + + "[white] and [#838ac7]Right[white] arrows, or alternatively [#838ac7]Tab" + + "[white] and [#838ac7]Shift+Tab[white]. Use [#838ac7]Escape[white] to remove" + + " focus from a form element." + +// createApp creates the Screen and adds the menu and the initial view. +func createApp() { + clientCore = core.New(&core.Config{ + DBPath: cfg.DBPath, // global set in config.go + Logger: NewLogger("CORE", nil), + Certs: cfg.Certs, + }) + go clientCore.Run(appCtx) + createWidgets() + // Create the Screen, which is the top-level layout manager. + flex := tview.NewFlex(). + AddItem(mainMenu, 25, 0, false) + screen = &Screen{ + Flex: flex, + right: appJournal, + focused: mainMenu, + } + // Initial view is the application journal. + setRightBox(appJournal) + setFocus(mainMenu) + noteLog.Infof(welcomeMessage) + // Print a message indicating the network. + switch { + case cfg.Simnet: + log.Infof("DEX network set to simnet") + case cfg.Testnet: + log.Infof("DEX network set to testnet") + default: + log.Warnf("DEX NETWORK SET TO MAINNET. DEX client software is in " + + "development and should not be used to perform trades on mainnet.") + } + // --rpc flag was set. + if cfg.RPCOn { + rpcView.toggle() + } + // --web flag was set. + if cfg.WebOn { + webView.toggle() + } +} + +// createWidgets creates all of the primitives. +func createWidgets() { + app = tview.NewApplication() + acctsView = newAccountsView() + marketView = newMarketView() + webView = newServerView("Web", cfg.WebAddr, func(ctx context.Context, addr string, logger slog.Logger) { + setWebLabelOn(true) + webSrv, err := webserver.New(clientCore, cfg.WebAddr, logger, cfg.ReloadHTML) + if err != nil { + log.Errorf("Error starting web server: %v", err) + return + } + webSrv.Run(ctx) + setWebLabelOn(false) + }) + rpcView = newServerView("RPC", cfg.RPCAddr, func(ctx context.Context, addr string, logger slog.Logger) { + setRPCLabelOn(true) + rpcserver.Run(ctx, clientCore, addr, logger) + setRPCLabelOn(false) + }) + noteJournal = newJournal("Notifications", handleNotificationLog) + noteLog = NewLogger("NOTIFICATION", func(msg []byte) { + setNotificationCount(int(atomic.AddUint32(¬ificationCount, 1))) + noteJournal.Write(msg) + }) + mainMenu = newMainMenu() +} + +// handleAppLogKey filters key presses when the application log view has focus. +func handleAppLogKey(e *tcell.EventKey) *tcell.EventKey { + return handleRightBox(e) +} + +// handleNotificationLog filters key presses when the notification log view has +// focus. +func handleNotificationLog(e *tcell.EventKey) *tcell.EventKey { + return handleRightBox(e) +} + +// handleRightBox provides a base set of key events for simple views. +func handleRightBox(e *tcell.EventKey) *tcell.EventKey { + switch e.Key() { + case tcell.KeyEscape: + setFocus(mainMenu) + return nil + } + return e +} + +// MAIN MENU + +// Main menu titles. +const ( + entryAppLog = "Application Log" + entryAccounts = "Accounts & Wallets" + entryMarkets = "Markets" + entryNotifications = "Notifications" + entryWebServer = "Web Server" + entryRPCServer = "RPC Server" + entryQuit = "Quit" +) + +// To modify the main menu text, you have to access the entry by index. These +// need to be set during instantiation. +var ( + noteEntryIdx int + webEntryIdx int + rpcEntryIdx int +) + +// newMainMenu is a constructor for main menu, which is just a *chooser. +func newMainMenu() *chooser { + c := newChooser("", handleMainMenuKey) + // Don't supply handlers to the chooser entries. KeyEnter will be handled in + // handleMainMenuKey. + c.addEntry(entryAppLog, nil). + addEntry(entryAccounts, nil). + addEntry(entryMarkets, nil). + addEntry(entryNotifications, nil). + addEntry(entryWebServer, nil). + addEntry(entryRPCServer, nil). + addEntry(entryQuit, nil) + noteEntryIdx = 3 + webEntryIdx = 4 + rpcEntryIdx = 5 + return c +} + +// handleMainMenuKey processes key presses from the main menu. +func handleMainMenuKey(e *tcell.EventKey) *tcell.EventKey { + entry, _ := mainMenu.GetItemText(mainMenu.GetCurrentItem()) + match := strings.HasPrefix + switch e.Key() { + case tcell.KeyBacktab, tcell.KeyTab, tcell.KeyEscape, tcell.KeyEnter: + switch { + case match(entry, entryAppLog): + setRightBox(appJournal) + case match(entry, entryAccounts): + setRightBox(acctsView) + case match(entry, entryMarkets): + setRightBox(marketView) + case match(entry, entryNotifications): + atomic.StoreUint32(¬ificationCount, 0) + setNotificationCount(0) + setRightBox(noteJournal) + case match(entry, entryWebServer): + setRightBox(webView) + case match(entry, entryRPCServer): + setRightBox(rpcView) + case match(entry, entryQuit): + app.Stop() + default: + setRightBox(screen.right) + } + return nil + } + return e +} + +// setRightBox set the currently displayed view, which is everything but the +// main menu. +func setRightBox(box focuser) { + screen.RemoveItem(screen.right) + screen.right = box + screen.AddItem(box, 0, 80, false) + setFocus(box) +} + +// setFocus adds focus to the focuser and removes focus from the last focuser. +func setFocus(wgt focuser) { + screen.focused.RemoveFocus() + screen.focused = wgt + wgt.AddFocus() +} + +// setWebLabelOn sets whether the main menu entry for the web server is +// appended with an indicator to show that the server is running. +func setWebLabelOn(on bool) { + app.QueueUpdateDraw(func() { + if on { + mainMenu.SetItemText(webEntryIdx, entryWebServer+" [green](on)", "") + return + } + mainMenu.SetItemText(webEntryIdx, entryWebServer, "") + }) +} + +// setWebLabelOn sets whether the main menu entry for the RPC server is +// appended with an indicator to show that the server is running. +func setRPCLabelOn(on bool) { + app.QueueUpdateDraw(func() { + if on { + mainMenu.SetItemText(rpcEntryIdx, entryRPCServer+" [green](on)", "") + return + } + mainMenu.SetItemText(rpcEntryIdx, entryRPCServer+"", "") + }) +} + +// setNotificationCount sets the notification count next to the notification +// entry in the main menu. +func setNotificationCount(n int) { + suffix := fmt.Sprintf(" [#fc8c03](%d)[white]", n) + if n == 0 { + suffix = "" + } + mainMenu.SetItemText(noteEntryIdx, entryNotifications+suffix, "") +} + +// CHOOSER + +// chooser is an tview List with some default settings. +type chooser struct { + *tview.List +} + +// newChooser is a constructor for a *chooser. The provided key filter +// will be applied on key presses. +func newChooser(title string, keyFunc inputCapture) *chooser { + list := tview.NewList() + list.SetBorder(true). + SetBorderColor(blurColor). + SetInputCapture(keyFunc). + SetTitle(title). + SetBorderPadding(1, 3, 1, 3) + + return &chooser{ + List: list, + } +} + +// addEntry adds the entry to the list, with a callback function to be invoked +// when the entry is chosen. nil handler is OK. +func (c *chooser) addEntry(name string, f func()) *chooser { + c.AddItem(name, "", 0, f) + return c +} + +// AddFocus is part of the focuser interface, and will be called when this +// element receives focus +func (c *chooser) AddFocus() { + c.SetBorderColor(focusColor) + app.SetFocus(c) +} + +// RemoveFocus is part of the focuser interface, and will be called when this +// element loses focus. +func (c *chooser) RemoveFocus() { + c.SetBorderColor(blurColor) +} + +// A simple button is a TextView that is used as a button. +type simpleButton struct { + *tview.TextView +} + +// newSimpleButton is a constructor for a *simpleButton. The provided callback +// function will be invoked when the button is "clicked". +func newSimpleButton(lbl string, f func()) *simpleButton { + bttn := tview.NewTextView(). + SetText(lbl). + SetTextAlign(tview.AlignCenter) + bttn.SetBorder(true).SetBorderColor(blurColor) + bttn.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey { + switch e.Key() { + case tcell.KeyEnter: + f() + default: + return e + } + return nil + }) + return &simpleButton{TextView: bttn} +} + +// AddFocus is part of the focuser interface, and will be called when this +// element receives focus. +func (b *simpleButton) AddFocus() { + b.SetBorderColor(focusColor) + app.SetFocus(b) +} + +// RemoveFocus is part of the focuser interface, and will be called when this +// element loses focus. +func (b *simpleButton) RemoveFocus() { + b.SetBorderColor(blurColor) +} + +// FOCUS CHAIN + +// focusChain enables control over the order of progression of changing focus, +// i.e. what element receives focus next when the user presses tab/backtab. +type focusChain struct { + parent focuser + chain []focuser + curIdx int +} + +// newFocusChain is a constructor for a *focusChain. +func newFocusChain(parent focuser, prims ...focuser) *focusChain { + c := &focusChain{ + parent: parent, + chain: prims, + } + // TO DO: This is a little sloppy. Since the wrapped handler is being + // re-assigned with SetInputCapture, this means an element should only be + // added to a single focus chain for its lifetime. Ideally, we could + // re-assign the element to a new focus chain if a new element is added or + // an element is removed. + for _, prim := range prims { + ogCapture := prim.GetInputCapture() + prim.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey { + e = c.handleInput(e) + if e != nil && ogCapture != nil { + return ogCapture(e) + } + return e + }) + } + return c +} + +// focus must be called from the parent view when it receives focus itself. +func (c *focusChain) focus() { + setFocus(c.chain[c.curIdx]) +} + +// handleInput is called before the chain element's input capture callback. +func (c *focusChain) handleInput(e *tcell.EventKey) *tcell.EventKey { + switch e.Key() { + case tcell.KeyLeft, tcell.KeyBacktab: + c.prev() + case tcell.KeyRight, tcell.KeyTab: + c.next() + case tcell.KeyEscape: + c.parent.RemoveFocus() + setFocus(mainMenu) + default: + return e + } + return nil +} + +// next moves focus to the next element in the chain. +func (c *focusChain) next() { + c.chain[c.curIdx].RemoveFocus() + c.curIdx = (c.curIdx + 1) % len(c.chain) + setFocus(c.chain[c.curIdx]) +} + +// prev moves focus to the previous element in the chain. +func (c *focusChain) prev() { + c.chain[c.curIdx].RemoveFocus() + chainLen := len(c.chain) + c.curIdx = (c.curIdx + chainLen - 1) % chainLen + setFocus(c.chain[c.curIdx]) +} + +// Flex alignment utilities. + +func verticallyCentered(prim tview.Primitive, h int) *tview.Flex { + flex := horizontallyCentered(prim, h) + return flex.SetDirection(tview.FlexRow) +} + +func horizontallyCentered(prim tview.Primitive, w int) *tview.Flex { + return tview.NewFlex(). + AddItem(tview.NewBox(), 0, 1, false). + AddItem(prim, w, 0, false). + AddItem(tview.NewBox(), 0, 1, false) +} + +func fullyCentered(prim tview.Primitive, w, h int) *tview.Flex { + flex := horizontallyCentered(prim, w) + return verticallyCentered(flex, h) +} + +func init() { + tview.Styles.PrimitiveBackgroundColor = backgroundColor +} diff --git a/client/core/core.go b/client/core/core.go index 18603dbe77..1181de4fca 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -77,9 +77,14 @@ func (c *Core) Run(ctx context.Context) { log.Errorf("database initialization error: %v", err) return } - c.ctx = ctx c.db = db - go c.initialize() + // Store the context as a field for now, since we will need to spawn new + // DEX threads when new accounts are registered. + c.ctx = ctx + // Have one thread just wait on context cancellation, since if there are no + // DEX accounts yet, there would be nothing else on the WaitGroup. + c.initialize() + <-ctx.Done() c.wg.Wait() log.Infof("DEX client core off") } @@ -108,6 +113,28 @@ func (c *Core) ListMarkets() []*MarketInfo { return infos } +func (c *Core) Register(*Registration) error { + return nil +} + +func (c *Core) Login(dex, pw string) error { + return nil +} + +func (c *Core) Sync(dex string, base, quote uint32) (chan *BookUpdate, error) { + return make(chan *BookUpdate), nil +} + +func (c *Core) Book(dex string, base, quote uint32) *OrderBook { + return nil +} + +func (c *Core) Unsync(dex string, base, quote uint32) {} + +func (c *Core) Balance(uint32) (uint64, error) { + return 0, nil +} + // initialize pulls the known DEX URLs from the database and attempts to // connect and retreive the DEX configuration. func (c *Core) initialize() { @@ -115,16 +142,12 @@ func (c *Core) initialize() { if err != nil { log.Errorf("Error retreiving accounts from database: %v", err) } - var wg sync.WaitGroup for _, uri := range dexs { - wg.Add(1) u := uri go func() { c.addDex(u) - wg.Done() }() } - wg.Wait() if len(dexs) > 0 { c.connMtx.RLock() log.Infof("Successfully connected to %d out of %d DEX servers", len(c.conns), len(dexs)) diff --git a/client/core/core_test.go b/client/core/core_test.go index c65d883252..286fe800f9 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -50,7 +50,6 @@ func randomMsgMarket() (baseAsset, quoteAsset *msgjson.Asset) { func testCore() *Core { return &Core{ - ctx: tCtx, conns: make(map[string]*dexConnection), } } diff --git a/client/core/types.go b/client/core/types.go index 3a0fca79c3..58e8547567 100644 --- a/client/core/types.go +++ b/client/core/types.go @@ -32,7 +32,7 @@ type Market struct { MarketBuyBuffer float32 `json:"buybuffer"` } -// Display returns a ID string suitable for displaying in a UI. +// Display returns an ID string suitable for displaying in a UI. func (m *Market) Display() string { return strings.ToUpper(m.BaseSymbol) + "-" + strings.ToUpper(m.QuoteSymbol) } diff --git a/client/rpcserver/rpcserver.go b/client/rpcserver/rpcserver.go new file mode 100644 index 0000000000..b5e4c49201 --- /dev/null +++ b/client/rpcserver/rpcserver.go @@ -0,0 +1,20 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package rpcserver + +import ( + "context" + + "decred.org/dcrdex/client/core" + "github.com/decred/slog" +) + +var log slog.Logger + +func Run(ctx context.Context, core *core.Core, addr string, logger slog.Logger) { + log = logger + log.Infof("RPC server running at %s", addr) + <-ctx.Done() + log.Infof("RPC server off") +} diff --git a/client/webserver/webserver.go b/client/webserver/webserver.go index b169bfebd7..2a87742a07 100644 --- a/client/webserver/webserver.go +++ b/client/webserver/webserver.go @@ -142,7 +142,7 @@ func New(core clientCore, addr string, logger slog.Logger, reloadHTML bool) (*We // Right now, it is expected that the working directory // is either the dcrdex root directory, or the WebServer directory itself. - root := "client/webserver/site" + root := "../../webserver/site" if !folderExists(root) { root = "site" if !folderExists(root) {