diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1575e1a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + build-test: + name: build & test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-14] + steps: + - uses: actions/checkout@v4 + + - name: Install Linux GUI dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libgl1-mesa-dev xorg-dev + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Format check + run: cargo fmt --all --check + + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Test + run: cargo test --workspace + + - name: Build + run: cargo build --workspace diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6f8e118 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,50 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + +jobs: + release: + name: ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: macos-14 + target: aarch64-apple-darwin + steps: + - uses: actions/checkout@v4 + + - name: Install Linux GUI dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libgl1-mesa-dev xorg-dev + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + + - name: Build release binary + run: cargo build --release --locked --target ${{ matrix.target }} --bin tickerwall + + - name: Package + run: | + tar -czf "tickerwall-${{ matrix.target }}.tar.gz" \ + -C "target/${{ matrix.target }}/release" tickerwall + + - name: Upload to release + uses: softprops/action-gh-release@v2 + with: + files: tickerwall-${{ matrix.target }}.tar.gz diff --git a/.gitignore b/.gitignore index b68b638..2c7f7fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,4 @@ .DS_Store -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - -# Dependency directories (remove the comment below to include it) -# vendor/ -logos/ \ No newline at end of file +# Rust build output (Cargo.lock is committed for the workspace binary) +/target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ebc744f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4199 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.19", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "femtovg" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aaa216522a4fe16ee14ea837c6ee54905a1d993e671ee06488732d4d9946827" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "fnv", + "glow", + "image", + "imgref", + "itertools 0.15.0", + "log", + "lru", + "rgb", + "rustybuzz", + "slotmap", + "ttf-parser", + "unicode-bidi", + "unicode-segmentation", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "263218b0ba2b0715c3370fb04674f5f8aa331594b521c94ff0a8e4a8472d1386" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.1", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.9", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.19", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools 0.14.0", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.19", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.19", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower 0.5.3", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.9", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tickerwall-app" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "serde", + "serde_json", + "serde_yaml", + "tickerwall-client", + "tickerwall-gui", + "tickerwall-leader", + "tickerwall-proto", + "tokio", + "tokio-util", + "toml", + "tonic", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tickerwall-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "futures", + "parking_lot", + "tickerwall-proto", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tracing", + "uuid", +] + +[[package]] +name = "tickerwall-data" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "chrono-tz", + "futures-util", + "reqwest", + "serde", + "serde_json", + "tickerwall-proto", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tracing", +] + +[[package]] +name = "tickerwall-gui" +version = "0.1.0" +dependencies = [ + "anyhow", + "femtovg", + "glutin", + "glutin-winit", + "raw-window-handle", + "tickerwall-client", + "tickerwall-proto", + "tokio", + "tokio-util", + "tracing", + "winit", +] + +[[package]] +name = "tickerwall-leader" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-stream", + "futures", + "parking_lot", + "prost", + "tickerwall-data", + "tickerwall-proto", + "tokio", + "tokio-util", + "tonic", + "tracing", +] + +[[package]] +name = "tickerwall-proto" +version = "0.1.0" +dependencies = [ + "prost", + "protoc-bin-vendored", + "tonic", + "tonic-build", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.7", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower 0.5.3", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.7", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..03c3e73 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,63 @@ +# Ticker wall — Cargo workspace. Builds the `tickerwall` binary (crates/app). +# See docs/ARCHITECTURE.md for the design. +[workspace] +resolver = "2" +members = [ + "crates/proto", + "crates/data", + "crates/leader", + "crates/client", + "crates/gui", + "crates/app", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" +repository = "https://github.com/massive-com/ticker-wall" + +[workspace.dependencies] +# gRPC / protobuf +tonic = "0.12" +prost = "0.13" +tonic-build = "0.12" +# Vendored protoc so builds need no system protobuf compiler (hermetic in CI). +protoc-bin-vendored = "3" + +# async runtime +tokio = { version = "1", features = ["full"] } +tokio-stream = "0.1" +futures = "0.3" + +# diagnostics +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" +thiserror = "1" + +# misc +uuid = { version = "1", features = ["v4"] } +parking_lot = "0.12" + +# market-data client +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +tokio-util = "0.7" +futures-util = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +serde_yaml = "0.9" +chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } +chrono-tz = "0.10" + +async-stream = "0.3" +clap = { version = "4", features = ["derive", "env"] } + +# internal crates +tickerwall-proto = { path = "crates/proto" } +tickerwall-data = { path = "crates/data" } +tickerwall-leader = { path = "crates/leader" } +tickerwall-client = { path = "crates/client" } +tickerwall-gui = { path = "crates/gui" } diff --git a/README.md b/README.md index ec2e522..3cc0f7e 100644 --- a/README.md +++ b/README.md @@ -2,123 +2,136 @@

-# Massive.com - Ticker Wall +# Massive.com — Ticker Wall -The Massive.com ticker wall is an open source, cross platform, scalable ticker tape. It is meant to be scalable across many machines to eliminate the need for expensive specialty hardware for achieving a scrolling ticker tape. It is cross platform compatible, so it runs on mac, windows or linux ( only mac and linux tested ). All interaction is done via the CLI. There is a gRPC interface for more advanced integrations. +The Massive.com ticker wall is an open source, cross-platform, horizontally +scalable scrolling stock ticker tape. It spans any number of side-by-side +displays (on one machine or many) to form a single continuous tape, so you can +build a large ticker wall out of commodity screens instead of specialty hardware. +It runs on macOS and Linux. All interaction is via the CLI, with a gRPC interface +underneath for advanced integrations. -We use it at the [Massive.com](https://massive.com) office, but we also wanted it to be general enough to suite a broad group of needs, so most interactions and settings are configurable. +We use it at the [Massive.com](https://massive.com) office, and it's configurable +enough to suit a broad range of setups. -# Getting Started +This is a **Rust** application (a Cargo workspace under `crates/`). It replaces the +original Go implementation; see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for +the design and [`docs/legacy/ARCHITECTURE.md`](docs/legacy/ARCHITECTURE.md) for the +retired Go version. -There are 2 components to a ticker wall cluster. There is 1x Leader and N number of GUIs. The leader can also be run on the same system as a GUI, and there is no minimum for the number of GUIs. You can start with 1 screen, then continue to add more and it will dynamically adjust in real-time. +## Features -Download the latest release binary from the [Releases Page](https://github.com/massive-com/go-app-ticker-wall/releases) +- One shared, continuous scrolling tape across N screens, updating live. +- Full-bleed intraday price graph behind every ticker (green/red by direction). +- A second **top gainers/losers** tape pinned to the bottom, on its own speed. +- Full-screen **announcements** with eased slide-in/out animations. +- Live control of colors, speeds, and layout over gRPC — no restart needed. +- Optional on-screen **FPS meter** for performance checks. -**Start the Leader** +## Getting Started -We must start the leader so that the GUIs can connect and get their data to display. +There are two roles in a cluster: **1 leader** and **N GUIs**. The leader can run +on the same machine as a GUI, and there's no minimum number of GUIs — start with +one screen and add more; the tape re-layouts in real time. -`./tickerwall server -a {myMassiveApiKey}` +Download the latest binary from the +[Releases page](https://github.com/massive-com/ticker-wall/releases), or +build from source (below). -**Run the GUI** +**Start the leader** (pulls data, serves gRPC on `:6886`): -`./tickerwall gui` - -**To run a second GUI** - -`./tickerwall gui --index=2` +``` +tickerwall server -a # or set TW_API_KEY +``` -# Configuration +**Run a GUI** (each is one screen/window): -Configuration of the applications are achieved via cli flags > env variables > configuration file. The application will search for a configuration file with the name of 'tickerwall' which can be in .yml, .json or .toml format. Environment variables overwrite config file settings, and command line flags overwrite env variables. +``` +tickerwall gui --screen-index 10 +tickerwall gui --screen-index 20 # a second screen, to its right +``` -# Updating settings +## Configuration -You can use the cli to update attributes of the cluster in real-time. Here are some examples: +Configuration resolves as **CLI flags > environment variables > built-in +defaults**. Environment variables are the flag name uppercased with a `TW_` +prefix — e.g. `--api-key` → `TW_API_KEY`, `--scroll-speed` → `TW_SCROLL_SPEED`, +`--ticker-box-width` → `TW_TICKER_BOX_WIDTH`. -Updating the scroll speed: +Run `tickerwall --help` for the full flag list. - ./tickerwall update --scroll-speed=5 +## Updating settings -Updating the background color to white: +Update cluster attributes in real time (only the flags you pass change): - ./tickerwall update --bg-color=255,255,255,255 +``` +tickerwall update --scroll-speed 5 +tickerwall update --bg-color 255,255,255,255 +tickerwall update --movers-scroll-speed 8 # the bottom tape's own speed +tickerwall update --show-fps true # toggle the FPS meter +``` -# Making Announcements +## Making announcements

-You can make announcements using the ticker wall using the following command: +``` +tickerwall announce "Big Announcement!" +tickerwall announce "Big Success!" --animation ease --type success +``` - ./tickerwall announce "Big Announcement!" +## Describe a cluster -You can change the color and animations: +``` +tickerwall describe +``` - ./tickerwall announce "Big Success!" --animation=ease --type=success +Prints the current settings, connected screens, tickers, and top movers. -# Describe a Cluster +## Building from source -You can describe a running cluster using the following: +``` +cargo build --release # produces target/release/tickerwall +``` - ./tickerwall describe +`protoc` is **not** required — the protobuf compiler is vendored via +`protoc-bin-vendored` and used automatically at build time. -Which should generate output that is similar to: +**Linux** needs X11 + OpenGL dev headers for the GUI: ``` -Global Viewport Size: 5760 px -Animation Duration: 500 ms -Scroll Speed: 5 -Ticker Box Width: 1100 px -Per Tick Updates: true -Screen Count: 3 -Screen Details: - ------------ - Screen ID: 73452516-62af-4720-be0a-b2d3f6bfc575 - - Width 1920 px - - Height 300 px - - Index 10 - ------------ - Screen ID: fd98cf41-c59d-46e5-8c12-832612912674 - - Width 1920 px - - Height 300 px - - Index 20 - ------------ - Screen ID: 5aac2e7a-23ef-4ba2-950a-58d434c42dfe - - Width 1920 px - - Height 300 px - - Index 30 - ------------ -Ticker count: 6 -Tickers: - - AAPL [ Apple Inc. ] - - AMD [ Advanced Micro Devices ] - - NVDA [ Nvidia Corp ] - - SBUX [ Starbucks Corp ] - - META [ Meta Platforms, Inc. Class A Common Stock ] - - HOOD [ Robinhood Markets, Inc. Class A Common Stock ] +# Debian/Ubuntu +sudo apt-get install -y libgl1-mesa-dev xorg-dev ``` -# Building from Source Prerequisites +**macOS** needs no extra packages. Windows is untested. -### Linux +## Development -On linux, the application requires X11. So you will need: `libgl1-mesa-dev` and `xorg-dev` packages. +A `justfile` provides shortcuts (`brew install just`): -### Mac - -No additional packages are required for Mac. - -### Windows +``` +just build # cargo build +just test # cargo test --workspace +just lint # cargo fmt --check + clippy -D warnings +just server # run the leader (needs TW_API_KEY) +just gui # run one GUI screen +just run # leader + two GUI screens +``` -Not sure, haven't been able to test it. +CI (GitHub Actions) builds, tests, lints (fmt + clippy) on Linux and macOS, and +publishes release binaries for `x86_64-unknown-linux-gnu` and +`aarch64-apple-darwin` on version tags (`v*`). -# TODO / Wish List +## Deployment -These are not in order of priority. +The deployed screens boot straight into the GUI fullscreen with no window manager +(bare X). See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the kiosk setup. -- Run inside docker container. -- Some kind of build process. tests? +## TODO / Wish list -- v2.0 - Instead of 2 separate roles ( Server and GUI(s)), use raft to establish the leader amongst GUIs. +- Config-file layer (`tickerwall.{yml,json,toml}`) — currently flags + env only. +- Run inside a Docker container. +- v2.0 — replace the explicit leader with Raft-based election among the GUIs. diff --git a/client/accessors.go b/client/accessors.go deleted file mode 100644 index 792cf68..0000000 --- a/client/accessors.go +++ /dev/null @@ -1,51 +0,0 @@ -package client - -import "github.com/massive-com/go-app-ticker-wall/v2/models" - -// GetTickers returns all the tickers we have. -func (t *ClusterClient) GetTickers() []*models.Ticker { - t.RLock() - defer t.RUnlock() - - return t.Tickers -} - -// GetSettings returns the presentation settings. -func (t *ClusterClient) GetSettings() *models.PresentationSettings { - t.RLock() - defer t.RUnlock() - - return t.Cluster.Settings -} - -// GetCluster returns the entire screen cluster. -func (t *ClusterClient) GetCluster() *models.ScreenCluster { - t.RLock() - defer t.RUnlock() - - return t.Cluster -} - -// GetScreen returns the local screen settings. -func (t *ClusterClient) GetScreen() *models.Screen { - t.RLock() - defer t.RUnlock() - - return t.Screen -} - -// GetAnnouncements returns the channel of announcements to be displayed. -func (t *ClusterClient) GetAnnouncements() chan *models.Announcement { - t.RLock() - defer t.RUnlock() - - return t.Announcements -} - -// GetStatus returns the clients status. -func (t *ClusterClient) GetStatus() *Status { - t.RLock() - defer t.RUnlock() - - return t.Status -} diff --git a/client/client.go b/client/client.go deleted file mode 100644 index c5f8594..0000000 --- a/client/client.go +++ /dev/null @@ -1,234 +0,0 @@ -package client - -import ( - "context" - "fmt" - "sync" - "time" - - "github.com/google/uuid" - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/sirupsen/logrus" - "google.golang.org/grpc" - tombv2 "gopkg.in/tomb.v2" -) - -// Client provides the basic endpoints needed to access the state of this client. -type Client interface { - GetTickers() []*models.Ticker - GetSettings() *models.PresentationSettings - GetCluster() *models.ScreenCluster - GetScreen() *models.Screen - GetAnnouncements() chan *models.Announcement - GetStatus() *Status - UpdateScreen(width, height int) -} - -const maxMessageSize = 1024 * 1024 * 1 // 1MB - -// ClusterClient keeps the client in sync with the leader. -type ClusterClient struct { - sync.RWMutex - config Config - - conn *grpc.ClientConn - client models.LeaderClient - - // State which will be kept in sync. - Screen *models.Screen - Tickers []*models.Ticker - Cluster *models.ScreenCluster - - // Announcements is a channel of announcements to display. - Announcements chan *models.Announcement - - Status *Status -} - -// New creates a new ticker wall client. -func New(cfg Config) (*ClusterClient, error) { - obj := &ClusterClient{ - config: cfg, - Status: &Status{ - GRPCStatus: GRPCStatusDisconnected, - }, - Screen: &models.Screen{ - UUID: uuid.NewString(), - Width: int32(cfg.ScreenWidth), - Height: int32(cfg.ScreenHeight), - Index: int32(cfg.ScreenIndex), - }, - Announcements: make(chan *models.Announcement, 100), - } - - return obj, nil -} - -// Run starts all of our go routines / listeners. -func (t *ClusterClient) Run(ctx context.Context) error { - // Create gRPC connection, close when done. - for { - // Check the context incase we cannot ever connect to leader. - if err := ctx.Err(); err != nil { - return err - } - - // Continue trying to connect to GRPC until we eventually connect. - if err := t.startGRPCClient(); err != nil { - logrus.Error("Could not create GRPC client to leader.") - continue - } - - // We connected, exit loop. - break - } - defer t.Close() - - // Create new tomb for this process. - tomb, ctx := tombv2.WithContext(ctx) - - // Join the leaders screen cluster, wait for updates. - tomb.Go(func() error { - return t.joinCluster(ctx) - }) - - return tomb.Wait() -} - -func (t *ClusterClient) joinCluster(ctx context.Context) error { - // Load in all ticker details. - if err := t.LoadTickers(ctx); err != nil { - return err - } - - // Join cluster, get read stream ( updateListener ) of events. - updateListener, err := t.client.JoinCluster(ctx, t.Screen) - if err != nil { - return err - } - - // Read loop. - for { - // Read message. - update, err := updateListener.Recv() - if err != nil { - logrus.WithError(err).Error("grpc client ending..") - - t.Status.GRPCStatus = GRPCStatusReconnecting - if err := t.startGRPCClient(); err != nil { - logrus.WithError(err).Error("grpc - could not reconnect... will continue trying...") - continue - } - - // Now that we are reconnected, start over. - return t.joinCluster(ctx) - } - - t.Status.GRPCStatus = GRPCStatusConnected - - if update == nil { - logrus.Warning("Update message empty...") - continue - } - - if err := t.processUpdate(update); err != nil { - return err - } - } -} - -func (t *ClusterClient) processUpdate(update *models.Update) error { - var err error - switch models.UpdateType(update.UpdateType) { - // Screen cluster has changed. - case models.UpdateTypeCluster: - t.updateScreenCluster(update.ScreenCluster) - - // Ticker added. - case models.UpdateTypeTickerAdded: - err = t.tickerAdded(update.Ticker) - - // Ticker removed. - case models.UpdateTypeTickerRemoved: - err = t.tickerRemoved(update.Ticker) - - // Ticker updated. - case models.UpdateTypeTickerUpdate: - // We can again use the tickerAdded method since we dedupe and replace. - err = t.tickerAdded(update.Ticker) - - // Price of a ticker updated. - case models.UpdateTypePrice: - err = t.tickerPriceUpdate(update.PriceUpdate) - - // We have a new announcement. - case models.UpdateTypeAnnouncement: - err = t.updateAnnouncement(update.Announcement) - - // We have a new announcement. - case models.UpdatePresentationSettings: - err = t.updatePresentationSettings(update.PresentationSettings) - - default: - logrus.WithField("updateType", update.UpdateType).Warning("Unknown update type message.") - } - - if err != nil { - return err - } - - return nil -} - -// Close cleans up our current grpc connection. -func (t *ClusterClient) Close() error { - return t.conn.Close() -} - -// startGRPCClient creates a new GRPC client connection. -func (t *ClusterClient) startGRPCClient() error { - logrus.Debug("Connect to gRPC Leader.") - - var opts []grpc.DialOption - opts = append(opts, - grpc.WithInsecure(), - grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxMessageSize)), - grpc.WithBlock(), - grpc.WithTimeout(5*time.Second), - ) - - conn, err := grpc.Dial(t.config.Leader, opts...) - if err != nil { - return fmt.Errorf("not able to connect to grpc ticker wall leader: %w", err) - } - - logrus.Debug("Connected TCP to Leader.") - - // Set our attributes. - t.conn = conn - t.client = models.NewLeaderClient(t.conn) - - logrus.Debug("Created new gRPC client to Leader.") - - return nil -} - -// nolint:unparam // This will become more complex in later PR. -func (t *ClusterClient) updateAnnouncement(announcement *models.Announcement) error { - logrus.Debug("Got announcement.. ", announcement) - - // Put the announcement into the queue for display. - t.Announcements <- announcement - - return nil -} - -// updatePresentationSettings updates our presentation settings. -func (t *ClusterClient) updatePresentationSettings(update *models.PresentationSettings) error { - t.Lock() - defer t.Unlock() - - t.Cluster.Settings = update - - return nil -} diff --git a/client/cluster.go b/client/cluster.go deleted file mode 100644 index 304ae01..0000000 --- a/client/cluster.go +++ /dev/null @@ -1,14 +0,0 @@ -package client - -import ( - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/sirupsen/logrus" -) - -func (t *ClusterClient) updateScreenCluster(cluster *models.ScreenCluster) { - logrus.Debug("Updating screen cluster information..") - t.Lock() - defer t.Unlock() - - t.Cluster = cluster -} diff --git a/client/env.go b/client/env.go deleted file mode 100644 index a31272f..0000000 --- a/client/env.go +++ /dev/null @@ -1,10 +0,0 @@ -package client - -type Config struct { - Leader string - - // Local Presentation Settings: - ScreenWidth int - ScreenHeight int - ScreenIndex int -} diff --git a/client/screen.go b/client/screen.go deleted file mode 100644 index 67f0091..0000000 --- a/client/screen.go +++ /dev/null @@ -1,26 +0,0 @@ -package client - -import ( - "context" - - "github.com/sirupsen/logrus" -) - -func (t *ClusterClient) UpdateScreen(width, height int) { - logrus.WithFields(logrus.Fields{ - "widht": width, - "height": height, - }).Debug("Updating screen attributes..") - - t.Lock() - t.Screen.Height = int32(height) - t.Screen.Width = int32(width) - t.Unlock() - - // Let the cluster know about our changes. - t.broadcastScreenUpdate() -} - -func (t *ClusterClient) broadcastScreenUpdate() { - t.client.UpdateScreen(context.Background(), t.Screen) -} diff --git a/client/status.go b/client/status.go deleted file mode 100644 index 50b9a01..0000000 --- a/client/status.go +++ /dev/null @@ -1,17 +0,0 @@ -package client - -type Status struct { - GRPCStatus GRPCStatus -} - -// GRPCStatus defines the current status of the given gRPC connection. -type GRPCStatus int - -const ( - // GRPCStatusConnected means the connection is established and currently connected. OK. - GRPCStatusConnected = 1 - // GRPCStatusReconnecting means the connection has closed, and we are trying to reconnect again. - GRPCStatusReconnecting = 2 - // GRPCStatusDisconnected means the connection has closed. - GRPCStatusDisconnected = 3 -) diff --git a/client/tickers.go b/client/tickers.go deleted file mode 100644 index 297aa32..0000000 --- a/client/tickers.go +++ /dev/null @@ -1,119 +0,0 @@ -package client - -import ( - "context" - "errors" - "sort" - - "github.com/massive-com/go-app-ticker-wall/v2/models" -) - -// tickerPriceUpdate handles updating a tickers price & market cap. -func (t *ClusterClient) tickerPriceUpdate(update *models.PriceUpdate) error { - t.Lock() - defer t.Unlock() - - for _, t := range t.Tickers { - if t.Ticker == update.Ticker { - t.Price = update.Price - t.MarketCap = float64(t.OutstandingShares) * update.Price - t.PriceChangePercentage = ((t.Price / t.PreviousClosePrice) - 1) * 100 - } - } - - return nil -} - -// tickerAdded handles adding a ticker to our local state. -func (t *ClusterClient) tickerAdded(ticker *models.Ticker) error { - t.Lock() - - // Try to update what we have, if we have it. - didUpdate := false - for i, tick := range t.Tickers { - if tick.Ticker == ticker.Ticker { - t.Tickers[i] = ticker - didUpdate = true - break - } - } - - // If we didn't have it already, add it. - if !didUpdate { - t.Tickers = append(t.Tickers, ticker) - } - - t.Unlock() - - err := t.tickerPriceUpdate(&models.PriceUpdate{ - Ticker: ticker.Ticker, - Price: ticker.Price, - }) - if err != nil { - return err - } - - t.sortAndTagTickers() - - return nil -} - -// tickerAdded handles removing a ticker from our local state. -func (t *ClusterClient) tickerRemoved(ticker *models.Ticker) error { - t.Lock() - - // Find index of the given ticker. - tickerIndex := -1 - for i, tick := range t.Tickers { - if tick.Ticker == ticker.Ticker { - tickerIndex = i - } - } - - // We didn't find this ticker?? - if tickerIndex == -1 { - t.Unlock() - return errors.New("unable to find ticker when attempting to remove it") - } - - // Remove the element from the slice. - t.Tickers[tickerIndex] = t.Tickers[len(t.Tickers)-1] - t.Tickers[len(t.Tickers)-1] = nil - t.Tickers = t.Tickers[:len(t.Tickers)-1] - - t.Unlock() - - t.sortAndTagTickers() - - return nil -} - -// LoadTickers requests the full list of tickers from leader. -func (t *ClusterClient) LoadTickers(ctx context.Context) error { - // Request full list of tickers from the leader. - tickers, err := t.client.GetTickers(ctx, &models.Empty{}) - if err != nil { - return err - } - - for _, ticker := range tickers.Tickers { - if err := t.tickerAdded(ticker); err != nil { - return err - } - } - - return nil -} - -func (t *ClusterClient) sortAndTagTickers() { - t.Lock() - defer t.Unlock() - - // Sort tickers (asc). - sort.Sort(models.TickerSlice(t.Tickers)) - - // Tag each ticker with it's Index. - for i, ticker := range t.Tickers { - ticker.Index = int32(i) - } -} diff --git a/cmd/cli/announce.go b/cmd/cli/announce.go deleted file mode 100644 index 847f2e4..0000000 --- a/cmd/cli/announce.go +++ /dev/null @@ -1,84 +0,0 @@ -package main - -import ( - "context" - - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" -) - -func newAnnounceCmd() *cobra.Command { - var leaderClient *ServerClient - - // Where the new settings will be put - announcement := &models.Announcement{} - var announcementType string - var announcementAnimation string - - cmd := &cobra.Command{ - Use: "announce [string to announce]", - Short: `Announce a message across the ticker wall.`, - Long: `Announce a message across the ticker wall.`, - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) (err error) { - // Create a leader client... - leader, _ := cmd.Flags().GetString("leader") - leaderClient, err = NewServerClient(leader) - if err != nil { - return err - } - logrus.Debug("Connected to Leader.") - - announcement.Animation = int32(getAnnouncementAnimation(announcementAnimation)) - announcement.AnnouncementType = int32(getAnnouncementType(announcementType)) - announcement.Message = args[0] - - if _, err = leaderClient.client.Announce(context.Background(), announcement); err != nil { - return err - } - - logrus.Info("Announcement Sent.") - - return nil - }, - } - - // Announcement params. - cmd.Flags().StringVarP(&announcementType, "type", "t", "info", "Announcement type. This determines the colors of the announcement. Valid options: ( info, danger, success )") - cmd.Flags().StringVarP(&announcementAnimation, "animation", "n", "elastic", "Announcement animation. Valid options: ( elastic, ease, back, bounce )") - cmd.Flags().Int64VarP(&announcement.LifespanMS, "lifespan", "i", 2000, "How long the message will be displayed on the ticker wall, in milliseconds.") - - // Dont auto sort flags. - cmd.Flags().SortFlags = false - - return cmd -} - -func getAnnouncementType(flagString string) models.AnnouncementType { - switch flagString { - case "info": - return models.AnnouncementTypeInfo - case "danger": - return models.AnnouncementTypeDanger - case "success": - return models.AnnouncementTypeSuccess - default: - return models.AnnouncementTypeInfo - } -} - -func getAnnouncementAnimation(flagString string) models.AnnouncementAnimation { - switch flagString { - case "elastic": - return models.AnnouncementAnimationElastic - case "bounce": - return models.AnnouncementAnimationBounce - case "ease": - return models.AnnouncementAnimationEase - case "back": - return models.AnnouncementAnimationBack - default: - return models.AnnouncementAnimationElastic - } -} diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go deleted file mode 100644 index e47bd81..0000000 --- a/cmd/cli/cli.go +++ /dev/null @@ -1,116 +0,0 @@ -package main - -import ( - "fmt" - "os" - "strings" - - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/spf13/viper" -) - -const ( - defaultConfigFilename = "tickerwall" - - // The environment variable prefix of all environment variables bound to our command line flags. - // For example, --debug is bound to TW_DEBUG. - envPrefix = "TW" -) - -func main() { - cmd := NewRootCommand() - if err := cmd.Execute(); err != nil { - logrus.Error("ERR: ", err) - os.Exit(1) - } -} - -// Build the cobra command that handles our command line tool. -func NewRootCommand() *cobra.Command { - // Root command. - rootCmd := &cobra.Command{ - Use: "tickerwall", - Short: "Massive.com Ticker Wall", - Long: `A horizontally scalable ticker wall to display real-time stock data. -Find out more at: https://github.com/massive-com/go-app-ticker-wall/v2`, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if err := initializeConfig(cmd); err != nil { - return err - } - - // Set log levels: - debug, _ := cmd.Flags().GetBool("debug") - logLevel := logrus.InfoLevel - if debug { - logLevel = logrus.DebugLevel - } - // Set Log Levels. - logrus.SetLevel(logLevel) - - return nil - }, - Run: func(cmd *cobra.Command, args []string) { - println("Use the --help command to learn more about this apps abilities.") - }, - } - - // Global flags. - rootCmd.PersistentFlags().StringP("api-key", "a", "", "Your Massive.com API Key. This key will be used to access Massive.com for data.") - rootCmd.PersistentFlags().StringP("leader", "l", "localhost:6886", "The leaders address of the cluster.") - rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug enables more verbose logging.") - - // Add additional commands. - rootCmd.AddCommand(newGUICmd()) - rootCmd.AddCommand(newServerCmd()) - rootCmd.AddCommand(newUpdateCmd()) - rootCmd.AddCommand(newAnnounceCmd()) - rootCmd.AddCommand(newDescribeCmd()) - - return rootCmd -} - -func initializeConfig(cmd *cobra.Command) error { - v := viper.New() - - v.SetConfigName(defaultConfigFilename) - v.AddConfigPath(".") - - // Get home dir. - home, err := os.UserHomeDir() - cobra.CheckErr(err) - - viper.AddConfigPath(home) - - if err := v.ReadInConfig(); err != nil { - // It's okay if there isn't a config file - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - return err - } - } - - v.SetEnvPrefix(envPrefix) - v.AutomaticEnv() - bindFlags(cmd, v) - - return nil -} - -// Bind each cobra flag to its associated viper configuration (config file and environment variable) -func bindFlags(cmd *cobra.Command, v *viper.Viper) { - cmd.Flags().VisitAll(func(f *pflag.Flag) { - // Environment variables can't have dashes in them, so bind them to their equivalent - // keys with underscores. - if strings.Contains(f.Name, "-") { - envVarSuffix := strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_")) - v.BindEnv(f.Name, fmt.Sprintf("%s_%s", envPrefix, envVarSuffix)) - } - - // Apply the viper config value to the flag when the flag is not set and viper has a value - if !f.Changed && v.IsSet(f.Name) { - val := v.Get(f.Name) - cmd.Flags().Set(f.Name, fmt.Sprintf("%v", val)) - } - }) -} diff --git a/cmd/cli/client.go b/cmd/cli/client.go deleted file mode 100644 index 0755284..0000000 --- a/cmd/cli/client.go +++ /dev/null @@ -1,62 +0,0 @@ -package main - -import ( - "fmt" - "time" - - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/sirupsen/logrus" - "google.golang.org/grpc" -) - -type ServerClient struct { - Leader string - conn *grpc.ClientConn - client models.LeaderClient -} - -func NewServerClient(leader string) (*ServerClient, error) { - obj := &ServerClient{ - Leader: leader, - } - if err := obj.setup(); err != nil { - return nil, err - } - return obj, nil -} - -func (s *ServerClient) setup() error { - if err := s.startGRPCClient(); err != nil { - return fmt.Errorf("unable to create grpc connection: %w", err) - } - return nil -} - -// startGRPCClient creates a new GRPC client connection. -const maxMessageSize = 1024 * 1024 * 1 // 1MB -func (s *ServerClient) startGRPCClient() error { - logrus.Debug("Connect to gRPC Leader.") - - var opts []grpc.DialOption - opts = append(opts, - grpc.WithInsecure(), - grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxMessageSize)), - grpc.WithBlock(), - grpc.WithTimeout(5*time.Second), - ) - - conn, err := grpc.Dial(s.Leader, opts...) - if err != nil { - return fmt.Errorf("not able to connect to grpc ticker wall leader: %w", err) - } - - logrus.Debug("Connected GRPC to Leader.") - - // Set our attributes. - s.conn = conn - s.client = models.NewLeaderClient(s.conn) - - logrus.Debug("Created new gRPC client to Leader.") - - return nil -} diff --git a/cmd/cli/color-utils.go b/cmd/cli/color-utils.go deleted file mode 100644 index f8c9e2d..0000000 --- a/cmd/cli/color-utils.go +++ /dev/null @@ -1,59 +0,0 @@ -package main - -import ( - "os" - "strconv" - "strings" - - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/sirupsen/logrus" -) - -type colorMap struct { - UpColor string `default:"51,255,51,255"` - DownColor string `default:"255,51,51,255"` - FontColor string `default:"255,255,255,255"` - TickerBoxBGColor string `default:"20,20,20,255"` - BGColor string `default:"1,1,1,255"` -} - -func parseColorMap(cmap *colorMap, cfg *models.PresentationSettings) { - cfg.UpColor = mapColorArrayToMap(cmap.UpColor) - cfg.DownColor = mapColorArrayToMap(cmap.DownColor) - cfg.FontColor = mapColorArrayToMap(cmap.FontColor) - cfg.TickerBoxBGColor = mapColorArrayToMap(cmap.TickerBoxBGColor) - cfg.BGColor = mapColorArrayToMap(cmap.BGColor) -} - -func mapColorArrayToMap(colorString string) *models.RGBA { - colors := strings.Split(colorString, ",") - - if len(colors) != 4 { - logrus.Debug("Color mapping does not have enough attributes. Requires 4, has: ", len(colors), " Value: ", strings.Join(colors, ",")) - os.Exit(1) - } - - red, err := strconv.Atoi(colors[0]) - if err != nil { - logrus.Error("Got error decoding reds value: ", colors[0]) - } - green, err := strconv.Atoi(colors[1]) - if err != nil { - logrus.Error("Got error decoding green value: ", colors[0]) - } - blue, err := strconv.Atoi(colors[2]) - if err != nil { - logrus.Error("Got error decoding blue value: ", colors[0]) - } - alpha, err := strconv.Atoi(colors[3]) - if err != nil { - logrus.Error("Got error decoding alpha value: ", colors[0]) - } - - return &models.RGBA{ - Red: int32(red), - Green: int32(green), - Blue: int32(blue), - Alpha: int32(alpha), - } -} diff --git a/cmd/cli/describe.go b/cmd/cli/describe.go deleted file mode 100644 index cd562e3..0000000 --- a/cmd/cli/describe.go +++ /dev/null @@ -1,74 +0,0 @@ -package main - -import ( - "context" - "fmt" - - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" -) - -func newDescribeCmd() *cobra.Command { - var leaderClient *ServerClient - - cmd := &cobra.Command{ - Use: "describe", - Short: `Describe a current running cluster.`, - Long: `Describe a current running cluster.`, - RunE: func(cmd *cobra.Command, args []string) (err error) { - // Create a leader client... - leader, _ := cmd.Flags().GetString("leader") - leaderClient, err = NewServerClient(leader) - if err != nil { - return err - } - logrus.Debug("Connected to Leader.") - - cluster, err := leaderClient.client.GetScreenCluster(context.Background(), &models.Empty{}) - if err != nil { - return err - } - - tickers, err := leaderClient.client.GetTickers(context.Background(), &models.Empty{}) - if err != nil { - return err - } - - printClusterInfo(cluster, tickers) - - return nil - }, - } - - // Dont auto sort flags. - cmd.Flags().SortFlags = false - - return cmd -} - -// printClusterInfo prints out the clusters details -// TODO: This would look a lot better as a table or something. -func printClusterInfo(cluster *models.ScreenCluster, tickers *models.Tickers) { - fmt.Println("Global Viewport Size:", cluster.GlobalViewportSize(), "px") - fmt.Println("Animation Duration:", cluster.Settings.AnimationDurationMS, "ms") - fmt.Println("Scroll Speed:", cluster.Settings.ScrollSpeed) - fmt.Println("Ticker Box Width:", cluster.Settings.TickerBoxWidth, "px") - fmt.Println("Per Tick Updates:", cluster.Settings.PerTickUpdates) - fmt.Println("Screen Count:", cluster.NumberOfScreens()) - fmt.Println("Screen Details:") - for _, screen := range cluster.Screens { - fmt.Println(" ------------ ") - fmt.Println(" Screen ID:", screen.UUID) - fmt.Println(" - Width", screen.Width, "px") - fmt.Println(" - Height", screen.Height, "px") - fmt.Println(" - Index", screen.Index) - } - fmt.Println(" ------------ ") - fmt.Println("Ticker count:", len(tickers.Tickers)) - fmt.Println("Tickers:") - - for _, t := range tickers.Tickers { - fmt.Println(" - ", t.Ticker, " [ ", t.CompanyName, " ]") - } -} diff --git a/cmd/cli/flags.go b/cmd/cli/flags.go deleted file mode 100644 index e488850..0000000 --- a/cmd/cli/flags.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/spf13/pflag" -) - -// colorFlags creates a flagset for the color options. -func colorFlags(colorMap *colorMap) *pflag.FlagSet { - colorFlags := pflag.NewFlagSet("color", pflag.ContinueOnError) - colorFlags.StringVarP(&colorMap.UpColor, "up-color", "", "51,255,51,255", "RGBA color mapping for the 'up' color. Array must be in order. red,green,blue,alpha.") - colorFlags.StringVarP(&colorMap.DownColor, "down-color", "", "255,51,51,255", "RGBA color mapping for the 'down' color. Array must be in order. red,green,blue,alpha.") - colorFlags.StringVarP(&colorMap.FontColor, "font-color", "", "255,255,255,255", "RGBA color mapping for the 'font' color. Array must be in order. red,green,blue,alpha.") - colorFlags.StringVarP(&colorMap.TickerBoxBGColor, "ticker-bg-color", "", "20,20,20,255", "RGBA color mapping for the 'font' color. Array must be in order. red,green,blue,alpha.") - colorFlags.StringVarP(&colorMap.BGColor, "bg-color", "", "1,1,1,255", "RGBA color mapping for the 'bg' color. Array must be in order. red,green,blue,alpha.") - return colorFlags -} - -// presentationFlags creates a flagset for the presentation options. -func presentationFlags(presentationSettings *models.PresentationSettings) *pflag.FlagSet { - presentationFlags := pflag.NewFlagSet("presentation", pflag.ContinueOnError) - presentationFlags.Int32VarP(&presentationSettings.ScrollSpeed, "scroll-speed", "s", 8, "How fast the tickers scroll across the screen. This is inverted so 1 is the fastest possible.") - presentationFlags.Int32VarP(&presentationSettings.TickerBoxWidth, "ticker-box-width", "w", 1100, "The size of the ticker box, in pixels.") - presentationFlags.Int32VarP(&presentationSettings.AnimationDurationMS, "animation-duration", "", 500, "Animation during of notifications, in milliseconds.") - presentationFlags.BoolVarP(&presentationSettings.PerTickUpdates, "per-tick-updates", "", true, "If the ticker wall should update on every trade which happens. Setting to false limits it to update 1/sec.") - return presentationFlags -} diff --git a/cmd/cli/gui.go b/cmd/cli/gui.go deleted file mode 100644 index 807128e..0000000 --- a/cmd/cli/gui.go +++ /dev/null @@ -1,35 +0,0 @@ -package main - -import ( - "os" - - "github.com/massive-com/go-app-ticker-wall/v2/gui" - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" -) - -func newGUICmd() *cobra.Command { - cfg := &gui.Config{} - - cmd := &cobra.Command{ - Use: "gui", - Short: `Start a new instance of the GUI.`, - Long: `Start a new instance of the GUI.`, - Run: func(cmd *cobra.Command, args []string) { - leader, _ := cmd.Flags().GetString("leader") - cfg.ClientConfig.Leader = leader - - // Actually start the GUI process. - if err := gui.Run(cfg); err != nil { - logrus.WithError(err).Error("GUI encountered an error.") - os.Exit(1) - } - }, - } - - cmd.Flags().IntVarP(&cfg.ClientConfig.ScreenHeight, "screen-height", "y", 300, "Height of this GUI window, in pixels.") - cmd.Flags().IntVarP(&cfg.ClientConfig.ScreenWidth, "screen-width", "x", 1600, "Width of this GUI window, in pixels.") - cmd.Flags().IntVarP(&cfg.ClientConfig.ScreenIndex, "screen-index", "i", 1, "Index of this GUI window in the window array. Eg: First screen: 10, Second screen: 20, and so on. This is an arbitrary number, used for sorting order.") - - return cmd -} diff --git a/cmd/cli/server.go b/cmd/cli/server.go deleted file mode 100644 index 49367e0..0000000 --- a/cmd/cli/server.go +++ /dev/null @@ -1,61 +0,0 @@ -package main - -import ( - "os" - - "github.com/massive-com/go-app-ticker-wall/v2/leader" - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/massive-com/go-app-ticker-wall/v2/server" - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" -) - -func newServerCmd() *cobra.Command { - cfg := &server.ServiceConfig{ - LeaderConfig: leader.Config{ - Presentation: &models.PresentationSettings{}, - }, - } - colorMap := &colorMap{} - - cmd := &cobra.Command{ - Use: "server", - Short: `Start a new instance of the Server.`, - Long: `Start a new instance of the Server.`, - Run: func(cmd *cobra.Command, args []string) { - parseColorMap(colorMap, cfg.LeaderConfig.Presentation) - - // Set the api key. - apiKey, _ := cmd.Flags().GetString("api-key") - cfg.LeaderConfig.APIKey = apiKey - - if cfg.LeaderConfig.APIKey == "" { - logrus.Error("You must set a Massive.com API Key. Use the '-a' param to set the key. Eg: tickerwall server -a MY_API_KEY.") - os.Exit(1) - } - - // Actually start the Server process. - if err := server.Run(cfg); err != nil { - logrus.WithError(err).Error("Server encountered an error.") - os.Exit(1) - } - }, - } - - cmd.Flags().StringVarP(&cfg.LeaderConfig.TickerList, "tickers", "t", "AAPL,AMD,NVDA,SBUX,META,HOOD", "A comma separated list of tickers to display on the ticker wall.") - - // Ports - cmd.Flags().IntVarP(&cfg.GRPCPort, "grpc-port", "g", 6886, "Which port the GRPC Server should bind to.") - cmd.Flags().IntVarP(&cfg.HTTPPort, "http-port", "p", 6887, "Which port the HTTP Server should bind to.") - - // Presentation Settings. - cmd.Flags().AddFlagSet(presentationFlags(cfg.LeaderConfig.Presentation)) - - // Color Settings. - cmd.Flags().AddFlagSet(colorFlags(colorMap)) - - // Dont auto sort flags. - cmd.Flags().SortFlags = false - - return cmd -} diff --git a/cmd/cli/update.go b/cmd/cli/update.go deleted file mode 100644 index 93eaf7e..0000000 --- a/cmd/cli/update.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "context" - - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" -) - -func newUpdateCmd() *cobra.Command { - var leaderClient *ServerClient - - // Where the new settings will be put - newSettings := &models.PresentationSettings{} - colorMap := &colorMap{} - - cmd := &cobra.Command{ - Use: "update", - Short: `Update settings of a currently running server.`, - Long: `Update settings of a currently running server.`, - RunE: func(cmd *cobra.Command, args []string) (err error) { - // Create a leader client... - leader, _ := cmd.Flags().GetString("leader") - leaderClient, err = NewServerClient(leader) - if err != nil { - return err - } - - logrus.Debug("Connected to Leader.") - - parseColorMap(colorMap, newSettings) - - if _, err = leaderClient.client.UpdatePresentationSettings(context.Background(), newSettings); err != nil { - return err - } - - logrus.Info("Settings Updated.") - - return nil - }, - } - - // Presentation Settings. - cmd.Flags().AddFlagSet(presentationFlags(newSettings)) - - // Color Settings. - cmd.Flags().AddFlagSet(colorFlags(colorMap)) - - // Dont auto sort flags. - cmd.Flags().SortFlags = false - - return cmd -} diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml new file mode 100644 index 0000000..766ad04 --- /dev/null +++ b/crates/app/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "tickerwall-app" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "tickerwall" +path = "src/main.rs" + +[dependencies] +tickerwall-proto.workspace = true +tickerwall-leader.workspace = true +tickerwall-client.workspace = true +tickerwall-gui.workspace = true +tonic.workspace = true +tokio.workspace = true +tokio-util.workspace = true +clap.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true +serde_yaml.workspace = true diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs new file mode 100644 index 0000000..dba5108 --- /dev/null +++ b/crates/app/src/main.rs @@ -0,0 +1,410 @@ +//! `tickerwall` CLI entry point: the `server`, `gui`, `update`, `announce`, and +//! `describe` subcommands. Configuration resolves as flags > environment +//! (`TW_*`) > built-in defaults, via clap's native `env` support. + +use std::sync::Arc; + +use anyhow::Result; +use clap::{Args, Parser, Subcommand}; +use tickerwall_proto::{PresentationSettings, Rgba}; +use tokio_util::sync::CancellationToken; + +#[derive(Parser)] +#[command(name = "tickerwall", version, about = "Massive.com Ticker Wall")] +struct Cli { + /// Enable verbose (debug) logging. + #[arg(short, long, global = true)] + debug: bool, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Start the leader: pull market data and serve it to GUIs over gRPC. + Server(ServerArgs), + /// Start a GUI window (one screen of the wall). + Gui(GuiArgs), + /// Update presentation settings of a running cluster (partial: only the flags + /// you pass are changed). + Update(UpdateArgs), + /// Announce a message across the wall. + Announce(AnnounceArgs), + /// Describe a running cluster (screens + tickers). + Describe(ClientArgs), +} + +#[derive(Args)] +struct UpdateArgs { + #[arg(short = 'l', long, default_value = "http://localhost:6886")] + leader: String, + #[arg(short = 's', long)] + scroll_speed: Option, + #[arg(short = 'w', long)] + ticker_box_width: Option, + #[arg(long)] + animation_duration: Option, + #[arg(long)] + per_tick_updates: Option, + #[arg(long)] + show_fps: Option, + #[arg(long)] + show_logos: Option, + #[arg(long)] + show_movers: Option, + #[arg(long)] + movers_scroll_speed: Option, + /// RGBA as "r,g,b,a" (e.g. 255,255,255,255). + #[arg(long)] + up_color: Option, + #[arg(long)] + down_color: Option, + #[arg(long)] + bg_color: Option, + #[arg(long)] + font_color: Option, + #[arg(long)] + ticker_bg_color: Option, +} + +#[derive(Args)] +struct AnnounceArgs { + /// The message to display. + message: String, + #[arg(short = 'l', long, default_value = "http://localhost:6886")] + leader: String, + /// info | danger | success + #[arg(short = 't', long, default_value = "info")] + r#type: String, + /// elastic | bounce | ease | back + #[arg(short = 'n', long, default_value = "elastic")] + animation: String, + /// How long the message stays up, in milliseconds. + #[arg(short = 'i', long, default_value_t = 2000)] + lifespan: i64, +} + +#[derive(Args)] +struct GuiArgs { + /// Leader gRPC address. + #[arg( + short = 'l', + long, + env = "TW_LEADER", + default_value = "http://localhost:6886" + )] + leader: String, + + /// Window height in pixels. + #[arg(short = 'y', long, env = "TW_SCREEN_HEIGHT", default_value_t = 300)] + screen_height: i32, + + /// Window width in pixels. + #[arg(short = 'x', long, env = "TW_SCREEN_WIDTH", default_value_t = 1920)] + screen_width: i32, + + /// Index of this screen in the wall (used for left-to-right ordering). + #[arg(short = 'i', long, env = "TW_SCREEN_INDEX", default_value_t = 1)] + screen_index: i32, + + /// Borderless-fullscreen on the current monitor (for kiosk deployment). + #[arg(short = 'f', long, env = "TW_FULLSCREEN", default_value_t = false)] + fullscreen: bool, +} + +#[derive(Args)] +struct ServerArgs { + /// Massive.com API key. + #[arg(short = 'a', long, env = "TW_API_KEY")] + api_key: String, + + /// Comma-separated ticker symbols to display. + #[arg( + short = 't', + long, + env = "TW_TICKERS", + default_value = "AAPL,AMD,NVDA,SBUX,META,HOOD" + )] + tickers: String, + + /// Port the gRPC server binds to. + #[arg(short = 'g', long, env = "TW_GRPC_PORT", default_value_t = 6886)] + grpc_port: u16, + + /// Scroll speed (inverted: 1 is fastest). + #[arg(short = 's', long, env = "TW_SCROLL_SPEED", default_value_t = 8)] + scroll_speed: i32, + + /// Ticker box width in pixels. + #[arg(short = 'w', long, env = "TW_TICKER_BOX_WIDTH", default_value_t = 1000)] + ticker_box_width: i32, + + /// Notification animation duration in milliseconds. + #[arg(long, env = "TW_ANIMATION_DURATION", default_value_t = 500)] + animation_duration: i32, + + /// Update on every trade (true) vs once per second (false). + #[arg(long, env = "TW_PER_TICK_UPDATES", default_value_t = true)] + per_tick_updates: bool, + + /// Show an FPS meter on each screen. + #[arg(long, env = "TW_SHOW_FPS", default_value_t = false)] + show_fps: bool, + + /// Show the secondary top gainers/losers tape at the bottom. + #[arg(long, env = "TW_SHOW_MOVERS", default_value_t = true)] + show_movers: bool, + + /// Scroll speed of the movers tape (inverted; 1 is fastest). + #[arg(long, env = "TW_MOVERS_SCROLL_SPEED", default_value_t = 5)] + movers_scroll_speed: i32, +} + +#[derive(Args)] +struct ClientArgs { + /// Leader gRPC address. + #[arg(short = 'l', long, default_value = "http://localhost:6886")] + leader: String, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + init_tracing(cli.debug); + + match cli.command { + // The GUI owns the main thread (OpenGL) and builds its own tokio runtime, + // so it must NOT run inside an outer runtime. + Command::Gui(args) => tickerwall_gui::run(tickerwall_gui::Config { + leader: args.leader, + screen_width: args.screen_width, + screen_height: args.screen_height, + screen_index: args.screen_index, + fullscreen: args.fullscreen, + }), + Command::Server(args) => block_on(run_server(args)), + Command::Update(args) => block_on(run_update(args)), + Command::Announce(args) => block_on(run_announce(args)), + Command::Describe(args) => block_on(run_describe(args)), + } +} + +/// Run an async task to completion on a fresh tokio runtime. +fn block_on>>(fut: F) -> Result<()> { + tokio::runtime::Runtime::new()?.block_on(fut) +} + +fn init_tracing(debug: bool) { + let default = if debug { "debug" } else { "info" }; + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default)); + tracing_subscriber::fmt().with_env_filter(filter).init(); +} + +async fn run_server(args: ServerArgs) -> Result<()> { + let mut settings = default_settings(); + settings.scroll_speed = args.scroll_speed; + settings.ticker_box_width = args.ticker_box_width; + settings.animation_duration_ms = args.animation_duration; + settings.per_tick_updates = args.per_tick_updates; + settings.show_fps = args.show_fps; + settings.show_movers = args.show_movers; + settings.movers_scroll_speed = args.movers_scroll_speed; + + let tickers = args + .tickers + .split(',') + .map(|s| s.trim().to_uppercase()) + .filter(|s| !s.is_empty()) + .collect(); + + let config = tickerwall_leader::Config { + api_key: args.api_key, + tickers, + settings, + }; + + let leader = Arc::new(tickerwall_leader::Leader::new(config)); + leader.load_initial_data().await?; + + let cancel = CancellationToken::new(); + leader.spawn_background(cancel.clone()); + + { + let cancel = cancel.clone(); + tokio::spawn(async move { + let _ = tokio::signal::ctrl_c().await; + tracing::info!("shutting down…"); + cancel.cancel(); + }); + } + + let addr = format!("0.0.0.0:{}", args.grpc_port).parse()?; + tickerwall_leader::serve_grpc(leader, addr, cancel).await +} + +async fn run_describe(args: ClientArgs) -> Result<()> { + use tickerwall_proto::leader_client::LeaderClient; + use tickerwall_proto::Empty; + + let mut client = LeaderClient::connect(args.leader).await?; + let snapshot = client.get_snapshot(Empty {}).await?.into_inner(); + print_snapshot(&snapshot); + Ok(()) +} + +async fn run_update(args: UpdateArgs) -> Result<()> { + use tickerwall_proto::leader_client::LeaderClient; + use tickerwall_proto::SettingsPatch; + + // Only fields the user passed become `Some`, so the leader merges them and + // leaves everything else untouched (fixes the Go "reset to defaults" bug). + let patch = SettingsPatch { + scroll_speed: args.scroll_speed, + ticker_box_width: args.ticker_box_width, + animation_duration_ms: args.animation_duration, + per_tick_updates: args.per_tick_updates, + show_fps: args.show_fps, + show_logos: args.show_logos, + show_movers: args.show_movers, + movers_scroll_speed: args.movers_scroll_speed, + up_color: parse_color_opt(&args.up_color)?, + down_color: parse_color_opt(&args.down_color)?, + bg_color: parse_color_opt(&args.bg_color)?, + font_color: parse_color_opt(&args.font_color)?, + ticker_box_bg_color: parse_color_opt(&args.ticker_bg_color)?, + }; + + let mut client = LeaderClient::connect(args.leader).await?; + client.update_settings(patch).await?; + tracing::info!("settings updated"); + Ok(()) +} + +async fn run_announce(args: AnnounceArgs) -> Result<()> { + use tickerwall_proto::leader_client::LeaderClient; + use tickerwall_proto::{AnnounceRequest, AnnouncementAnimation, AnnouncementType}; + + let r#type = match args.r#type.as_str() { + "danger" => AnnouncementType::Danger, + "success" => AnnouncementType::Success, + _ => AnnouncementType::Info, + } as i32; + let animation = match args.animation.as_str() { + "bounce" => AnnouncementAnimation::Bounce, + "ease" => AnnouncementAnimation::Ease, + "back" => AnnouncementAnimation::Back, + _ => AnnouncementAnimation::Elastic, + } as i32; + + let mut client = LeaderClient::connect(args.leader).await?; + client + .announce(AnnounceRequest { + message: args.message, + r#type, + lifespan_ms: args.lifespan, + animation, + }) + .await?; + tracing::info!("announcement sent"); + Ok(()) +} + +/// Parse an optional "r,g,b,a" string into an `Rgba`. +fn parse_color_opt(s: &Option) -> Result> { + match s { + Some(s) => Ok(Some(parse_color(s)?)), + None => Ok(None), + } +} + +fn parse_color(s: &str) -> Result { + let parts: Vec<&str> = s.split(',').collect(); + if parts.len() != 4 { + anyhow::bail!("color must be 'r,g,b,a' (4 values), got: {s}"); + } + let n = |i: usize| -> Result { + parts[i] + .trim() + .parse::() + .map_err(|e| anyhow::anyhow!("invalid color component '{}': {e}", parts[i])) + }; + Ok(rgba(n(0)?, n(1)?, n(2)?, n(3)?)) +} + +fn print_snapshot(snapshot: &tickerwall_proto::Snapshot) { + let cluster = match &snapshot.cluster { + Some(c) => c, + None => { + println!("(no cluster data)"); + return; + } + }; + if let Some(s) = &cluster.settings { + println!( + "Global Viewport Size: {} px", + cluster.global_viewport_size() + ); + println!("Animation Duration: {} ms", s.animation_duration_ms); + println!("Scroll Speed: {}", s.scroll_speed); + println!("Ticker Box Width: {} px", s.ticker_box_width); + println!("Per Tick Updates: {}", s.per_tick_updates); + } + println!("Screen Count: {}", cluster.number_of_screens()); + println!("Screen Details:"); + for screen in &cluster.screens { + println!(" ------------ "); + println!(" Screen ID: {}", screen.uuid); + println!(" - Width {} px", screen.width); + println!(" - Height {} px", screen.height); + println!(" - Index {}", screen.index); + } + println!(" ------------ "); + println!("Ticker count: {}", snapshot.tickers.len()); + println!("Tickers:"); + for t in &snapshot.tickers { + println!(" - {} [ {} ]", t.symbol, t.company_name); + } + + let movers = snapshot + .movers + .as_ref() + .map(|m| m.movers.as_slice()) + .unwrap_or(&[]); + println!(" ------------ "); + println!("Market movers: {}", movers.len()); + for m in movers { + println!( + " - {:<6} {:>10.2} ({:+.2}%)", + m.symbol, m.price, m.todays_change_percentage + ); + } +} + +/// Default presentation settings (mirror the Go CLI defaults). +fn default_settings() -> PresentationSettings { + PresentationSettings { + ticker_box_width: 1000, + scroll_speed: 8, + up_color: Some(rgba(51, 255, 51, 255)), + down_color: Some(rgba(255, 51, 51, 255)), + bg_color: Some(rgba(1, 1, 1, 255)), + font_color: Some(rgba(255, 255, 255, 255)), + ticker_box_bg_color: Some(rgba(20, 20, 20, 255)), + show_logos: false, + show_fps: false, + animation_duration_ms: 500, + per_tick_updates: true, + show_movers: true, + movers_scroll_speed: 5, + } +} + +fn rgba(red: i32, green: i32, blue: i32, alpha: i32) -> Rgba { + Rgba { + red, + green, + blue, + alpha, + } +} diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml new file mode 100644 index 0000000..bb51b2a --- /dev/null +++ b/crates/client/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "tickerwall-client" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +tickerwall-proto.workspace = true +tonic.workspace = true +tokio.workspace = true +tokio-stream.workspace = true +tokio-util.workspace = true +futures.workspace = true +tracing.workspace = true +anyhow.workspace = true +uuid.workspace = true +parking_lot.workspace = true diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs new file mode 100644 index 0000000..8eb093d --- /dev/null +++ b/crates/client/src/lib.rs @@ -0,0 +1,565 @@ +//! GUI-side cluster client: connects to the leader over gRPC, joins the cluster, +//! and keeps a synchronized local copy of state behind a lock the render loop +//! reads each frame. Replaces the Go `client` package. + +use std::sync::Arc; +use std::time::Duration; + +use parking_lot::{Mutex, RwLock}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use tickerwall_proto::leader_client::LeaderClient; +use tickerwall_proto::{ + Announcement, Empty, JoinRequest, Mover, PresentationSettings, PriceUpdate, Screen, + ScreenCluster, Ticker, Update, UpdateKind, +}; + +use tonic::transport::Channel; + +/// How long to wait between (re)connection / retry attempts. +const RETRY_DELAY: Duration = Duration::from_secs(1); + +/// Configuration for a [`ClusterClient`]. +pub struct Config { + /// Leader address, including scheme (e.g. `"http://localhost:6886"`). + pub leader: String, + pub screen_width: i32, + pub screen_height: i32, + pub screen_index: i32, +} + +/// Current state of the gRPC connection to the leader. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum GrpcStatus { + Connected, + Reconnecting, + Disconnected, +} + +/// Everything the render loop needs for a single frame, captured in one lock +/// acquisition (see [`ClusterClient::render_snapshot`]). +pub struct RenderSnapshot { + pub screen: Screen, + pub cluster: Option, + pub tickers: Vec, + pub status: GrpcStatus, + /// Announcements received since the previous frame (drained from the queue). + pub new_announcements: Vec, + pub movers: Vec, +} + +/// Mutable state kept in sync with the leader. Guarded by a single lock so the +/// render loop can cheaply snapshot it each frame. +struct Inner { + screen: Screen, + tickers: Vec, + cluster: Option, + status: GrpcStatus, + announcements: Vec, + movers: Vec, +} + +/// Keeps the local client state in sync with the leader. +pub struct ClusterClient { + leader: String, + inner: RwLock, + /// Sender used by [`ClusterClient::request_resize`] to hand screen updates + /// off to the async resize task spawned in [`ClusterClient::run`]. + resize_tx: Mutex>>, +} + +impl ClusterClient { + /// Create a client. Generates a random screen uuid and builds the local + /// [`Screen`] from `config`. Does not connect; call [`ClusterClient::run`]. + pub fn new(config: Config) -> Arc { + let screen = Screen { + uuid: Uuid::new_v4().to_string(), + width: config.screen_width, + height: config.screen_height, + index: config.screen_index, + }; + + Arc::new(Self { + leader: config.leader, + inner: RwLock::new(Inner { + screen, + tickers: Vec::new(), + cluster: None, + status: GrpcStatus::Disconnected, + announcements: Vec::new(), + movers: Vec::new(), + }), + resize_tx: Mutex::new(None), + }) + } + + /// Connect (retry until success or cancel), join the cluster, process the + /// update stream, and auto-reconnect. Returns when `cancel` fires. + pub async fn run(self: Arc, cancel: CancellationToken) -> anyhow::Result<()> { + // Wire up the resize channel + background pusher task. + let (tx, rx) = mpsc::unbounded_channel::(); + *self.resize_tx.lock() = Some(tx); + let resize_task = tokio::spawn(resize_loop(self.leader.clone(), rx, cancel.clone())); + + self.set_status(GrpcStatus::Disconnected); + + let result = self.clone().stream_loop(&cancel).await; + + // Tear down the resize task: drop the sender so the channel closes, then + // wait for it to observe cancellation and exit. + self.resize_tx.lock().take(); + let _ = resize_task.await; + + result + } + + /// Main connect -> seed -> join -> stream loop with automatic reconnect. + async fn stream_loop(self: Arc, cancel: &CancellationToken) -> anyhow::Result<()> { + loop { + if cancel.is_cancelled() { + return Ok(()); + } + + // Connect, retrying until success or cancellation. + let mut client = tokio::select! { + _ = cancel.cancelled() => return Ok(()), + c = self.connect_retry() => c, + }; + + // A session runs until the stream ends/errors or we are cancelled. + match self.session(&mut client, cancel).await { + Ok(()) => return Ok(()), // only returns Ok when cancelled + Err(err) => { + tracing::warn!(error = %err, "cluster session ended; reconnecting"); + self.set_status(GrpcStatus::Reconnecting); + tokio::select! { + _ = cancel.cancelled() => return Ok(()), + _ = tokio::time::sleep(RETRY_DELAY) => {} + } + } + } + } + } + + /// Connect to the leader, retrying with a short delay until it succeeds. + async fn connect_retry(&self) -> LeaderClient { + loop { + match LeaderClient::connect(self.leader.clone()).await { + Ok(client) => return client, + Err(err) => { + tracing::warn!(error = %err, "could not connect to leader; retrying"); + self.set_status(GrpcStatus::Reconnecting); + tokio::time::sleep(RETRY_DELAY).await; + } + } + } + } + + /// Seed state from a snapshot, join the cluster, and process updates until + /// the stream ends/errors (returns `Err`) or `cancel` fires (returns `Ok`). + async fn session( + &self, + client: &mut LeaderClient, + cancel: &CancellationToken, + ) -> anyhow::Result<()> { + // Seed tickers + cluster from the one-shot snapshot. + let snapshot = client.get_snapshot(Empty {}).await?.into_inner(); + { + let mut guard = self.inner.write(); + guard.cluster = snapshot.cluster; + let mut tickers = snapshot.tickers; + tickerwall_proto::sort_and_tag_tickers(&mut tickers); + guard.tickers = tickers; + guard.movers = snapshot.movers.map(|m| m.movers).unwrap_or_default(); + } + + // Open the update stream. + let join = JoinRequest { + screen: Some(self.screen()), + }; + let mut stream = client.join_cluster(join).await?.into_inner(); + + self.set_status(GrpcStatus::Connected); + + loop { + tokio::select! { + _ = cancel.cancelled() => return Ok(()), + message = stream.message() => match message { + Ok(Some(update)) => self.apply_update(update), + Ok(None) => anyhow::bail!("update stream closed by leader"), + Err(status) => anyhow::bail!("update stream error: {status}"), + }, + } + } + } + + // ---- state mutation (unit-testable without a server) ------------------ + + /// Apply a single streamed [`Update`] to local state. + fn apply_update(&self, update: Update) { + let Some(kind) = update.kind else { + tracing::warn!("received empty update"); + return; + }; + + match kind { + UpdateKind::Cluster(cluster) => { + self.inner.write().cluster = Some(cluster); + } + UpdateKind::TickerUpserted(ticker) => self.upsert_ticker(ticker), + UpdateKind::TickerRemoved(symbol) => self.remove_ticker(&symbol), + UpdateKind::Price(price) => self.apply_price(price), + UpdateKind::Announcement(announcement) => { + self.inner.write().announcements.push(announcement); + } + UpdateKind::Settings(settings) => { + let mut guard = self.inner.write(); + match guard.cluster.as_mut() { + Some(cluster) => cluster.settings = Some(settings), + None => { + guard.cluster = Some(ScreenCluster { + settings: Some(settings), + screens: Vec::new(), + }) + } + } + } + UpdateKind::Movers(m) => { + self.inner.write().movers = m.movers; + } + } + } + + /// Insert or replace a ticker by symbol, re-tag indices, then recompute its + /// derived price fields. + fn upsert_ticker(&self, ticker: Ticker) { + let symbol = ticker.symbol.clone(); + let price = ticker.price; + + { + let mut guard = self.inner.write(); + if let Some(existing) = guard.tickers.iter_mut().find(|t| t.symbol == symbol) { + *existing = ticker; + } else { + guard.tickers.push(ticker); + } + tickerwall_proto::sort_and_tag_tickers(&mut guard.tickers); + } + + // Recompute market cap / change percentage from the ticker's own price. + self.apply_price(PriceUpdate { symbol, price }); + } + + /// Remove a ticker by symbol and re-tag indices. + fn remove_ticker(&self, symbol: &str) { + let mut guard = self.inner.write(); + guard.tickers.retain(|t| t.symbol != symbol); + tickerwall_proto::sort_and_tag_tickers(&mut guard.tickers); + } + + /// Apply a price update: set price and recompute market cap + change + /// percentage. Guards against a zero previous close. + fn apply_price(&self, update: PriceUpdate) { + let mut guard = self.inner.write(); + if let Some(ticker) = guard.tickers.iter_mut().find(|t| t.symbol == update.symbol) { + ticker.price = update.price; + ticker.market_cap = ticker.outstanding_shares as f64 * update.price; + if ticker.previous_close_price != 0.0 { + ticker.price_change_percentage = + ((ticker.price / ticker.previous_close_price) - 1.0) * 100.0; + } + } + } + + fn set_status(&self, status: GrpcStatus) { + self.inner.write().status = status; + } + + // ---- read accessors --------------------------------------------------- + + /// Capture all render-loop state under a single lock, draining any queued + /// announcements. The render loop calls this once per frame instead of the + /// separate `screen`/`settings`/`cluster`/`tickers`/`status`/ + /// `drain_announcements` accessors (~7 lock acquisitions → 1), which reduces + /// contention with the price-update writer under a busy feed. + pub fn render_snapshot(&self) -> RenderSnapshot { + let mut inner = self.inner.write(); + RenderSnapshot { + screen: inner.screen.clone(), + cluster: inner.cluster.clone(), + tickers: inner.tickers.clone(), + status: inner.status, + new_announcements: std::mem::take(&mut inner.announcements), + movers: inner.movers.clone(), + } + } + + pub fn tickers(&self) -> Vec { + self.inner.read().tickers.clone() + } + + pub fn settings(&self) -> Option { + self.inner.read().cluster.as_ref().and_then(|c| c.settings) + } + + pub fn cluster(&self) -> Option { + self.inner.read().cluster.clone() + } + + pub fn screen(&self) -> Screen { + self.inner.read().screen.clone() + } + + pub fn status(&self) -> GrpcStatus { + self.inner.read().status + } + + /// Drain any announcements received since the last call. + pub fn drain_announcements(&self) -> Vec { + std::mem::take(&mut self.inner.write().announcements) + } + + /// Update the local screen size and asynchronously push it to the leader. + /// Non-blocking: safe to call from the GUI main thread. + pub fn request_resize(&self, width: i32, height: i32) { + let screen = { + let mut guard = self.inner.write(); + guard.screen.width = width; + guard.screen.height = height; + guard.screen.clone() + }; + + if let Some(tx) = self.resize_tx.lock().as_ref() { + // Unbounded send never blocks; only fails if the task has stopped. + let _ = tx.send(screen); + } + } +} + +/// Background task that pushes screen-resize updates to the leader. Owns its own +/// connection so a resize during a reconnect still eventually lands. +async fn resize_loop( + leader: String, + mut rx: mpsc::UnboundedReceiver, + cancel: CancellationToken, +) { + let mut client: Option> = None; + + loop { + let screen = tokio::select! { + _ = cancel.cancelled() => break, + msg = rx.recv() => match msg { + Some(screen) => screen, + None => break, // sender dropped + }, + }; + + // Lazily (re)connect. + if client.is_none() { + match LeaderClient::connect(leader.clone()).await { + Ok(connected) => client = Some(connected), + Err(err) => { + tracing::warn!(error = %err, "resize: could not connect to leader"); + continue; + } + } + } + + if let Some(inner) = client.as_mut() { + if let Err(status) = inner.update_screen(screen).await { + tracing::warn!(error = %status, "resize: update_screen failed"); + // Force a reconnect on the next resize. + client = None; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_client() -> Arc { + ClusterClient::new(Config { + leader: "http://localhost:6886".to_string(), + screen_width: 1920, + screen_height: 1080, + screen_index: 0, + }) + } + + fn ticker(symbol: &str, price: f64) -> Ticker { + Ticker { + symbol: symbol.to_string(), + price, + ..Default::default() + } + } + + fn upsert(t: Ticker) -> Update { + Update { + kind: Some(UpdateKind::TickerUpserted(t)), + } + } + + #[test] + fn upsert_inserts_dedupes_and_tags() { + let client = test_client(); + + client.apply_update(upsert(ticker("NVDA", 100.0))); + client.apply_update(upsert(ticker("AAPL", 200.0))); + client.apply_update(upsert(ticker("MSFT", 300.0))); + + let tickers = client.tickers(); + assert_eq!(tickers.len(), 3); + // Sorted ascending by symbol with re-tagged indices. + assert_eq!(tickers[0].symbol, "AAPL"); + assert_eq!(tickers[0].index, 0); + assert_eq!(tickers[1].symbol, "MSFT"); + assert_eq!(tickers[1].index, 1); + assert_eq!(tickers[2].symbol, "NVDA"); + assert_eq!(tickers[2].index, 2); + + // Upserting an existing symbol replaces rather than duplicates. + client.apply_update(upsert(ticker("AAPL", 250.0))); + let tickers = client.tickers(); + assert_eq!(tickers.len(), 3); + let aapl = tickers.iter().find(|t| t.symbol == "AAPL").unwrap(); + assert_eq!(aapl.price, 250.0); + } + + #[test] + fn price_update_recomputes_derived_fields() { + let client = test_client(); + + client.apply_update(upsert(Ticker { + symbol: "AAPL".into(), + outstanding_shares: 10, + previous_close_price: 100.0, + ..Default::default() + })); + + client.apply_price(PriceUpdate { + symbol: "AAPL".into(), + price: 110.0, + }); + + let tickers = client.tickers(); + let aapl = &tickers[0]; + assert_eq!(aapl.price, 110.0); + assert_eq!(aapl.market_cap, 10.0 * 110.0); + // ((110/100) - 1) * 100 == 10% + assert!((aapl.price_change_percentage - 10.0).abs() < 1e-9); + } + + #[test] + fn price_update_does_not_panic_on_zero_previous_close() { + let client = test_client(); + + client.apply_update(upsert(Ticker { + symbol: "AAPL".into(), + outstanding_shares: 5, + previous_close_price: 0.0, + ..Default::default() + })); + + client.apply_price(PriceUpdate { + symbol: "AAPL".into(), + price: 42.0, + }); + + let tickers = client.tickers(); + let aapl = &tickers[0]; + assert_eq!(aapl.price, 42.0); + assert_eq!(aapl.market_cap, 5.0 * 42.0); + // Percentage left at its default when previous close is zero. + assert_eq!(aapl.price_change_percentage, 0.0); + } + + #[test] + fn removal_removes_and_retags() { + let client = test_client(); + client.apply_update(upsert(ticker("AAPL", 1.0))); + client.apply_update(upsert(ticker("MSFT", 2.0))); + client.apply_update(upsert(ticker("NVDA", 3.0))); + + client.apply_update(Update { + kind: Some(UpdateKind::TickerRemoved("MSFT".to_string())), + }); + + let tickers = client.tickers(); + assert_eq!(tickers.len(), 2); + assert_eq!(tickers[0].symbol, "AAPL"); + assert_eq!(tickers[0].index, 0); + assert_eq!(tickers[1].symbol, "NVDA"); + assert_eq!(tickers[1].index, 1); + } + + #[test] + fn announcements_queue_and_drain() { + let client = test_client(); + assert!(client.drain_announcements().is_empty()); + + client.apply_update(Update { + kind: Some(UpdateKind::Announcement(Announcement { + message: "hello".into(), + ..Default::default() + })), + }); + client.apply_update(Update { + kind: Some(UpdateKind::Announcement(Announcement { + message: "world".into(), + ..Default::default() + })), + }); + + let drained = client.drain_announcements(); + assert_eq!(drained.len(), 2); + assert_eq!(drained[0].message, "hello"); + assert_eq!(drained[1].message, "world"); + // Draining empties the queue. + assert!(client.drain_announcements().is_empty()); + } + + #[test] + fn settings_update_replaces_cluster_settings() { + let client = test_client(); + + // Seed a cluster first. + client.apply_update(Update { + kind: Some(UpdateKind::Cluster(ScreenCluster { + settings: Some(PresentationSettings { + scroll_speed: 1, + ..Default::default() + }), + screens: Vec::new(), + })), + }); + + client.apply_update(Update { + kind: Some(UpdateKind::Settings(PresentationSettings { + scroll_speed: 9, + ticker_box_width: 1234, + ..Default::default() + })), + }); + + let settings = client.settings().expect("settings present"); + assert_eq!(settings.scroll_speed, 9); + assert_eq!(settings.ticker_box_width, 1234); + // Cluster still present, screens untouched. + assert_eq!(client.cluster().unwrap().screens.len(), 0); + } + + #[test] + fn request_resize_updates_local_screen() { + let client = test_client(); + // No resize task running (run() not called): must not panic. + client.request_resize(800, 600); + let screen = client.screen(); + assert_eq!(screen.width, 800); + assert_eq!(screen.height, 600); + } +} diff --git a/crates/data/Cargo.toml b/crates/data/Cargo.toml new file mode 100644 index 0000000..258e571 --- /dev/null +++ b/crates/data/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "tickerwall-data" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +tickerwall-proto.workspace = true +tokio.workspace = true +tokio-tungstenite.workspace = true +tokio-util.workspace = true +futures-util.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +chrono.workspace = true +chrono-tz.workspace = true +tracing.workspace = true +anyhow.workspace = true + +[dev-dependencies] +tokio.workspace = true +tokio-util.workspace = true diff --git a/crates/data/src/lib.rs b/crates/data/src/lib.rs new file mode 100644 index 0000000..4d1a9b0 --- /dev/null +++ b/crates/data/src/lib.rs @@ -0,0 +1,304 @@ +//! Market-data client for Massive.com (Polygon). REST for ticker details, prices, +//! and minute aggregates; a WebSocket feed for live prices. Returns +//! `tickerwall-proto` domain types directly. Replaces the Go `massive_client`. + +mod ws; + +use anyhow::{Context, Result}; +use chrono::{Datelike, NaiveDate, TimeZone, Utc, Weekday}; +use chrono_tz::America::New_York; +use serde::Deserialize; +use tickerwall_proto::{Agg, Mover, Ticker}; + +/// Which side of the top-movers snapshot to fetch. +#[derive(Clone, Copy, Debug)] +pub enum MoverDirection { + Gainers, + Losers, +} + +impl MoverDirection { + fn path(self) -> &'static str { + match self { + MoverDirection::Gainers => "gainers", + MoverDirection::Losers => "losers", + } + } +} + +/// Default REST base host (mirrors `massive-com/client-go`). +pub const DEFAULT_REST_BASE: &str = "https://api.massive.com"; +/// Default real-time stocks WebSocket endpoint. +pub const DEFAULT_WS_URL: &str = "wss://socket.massive.com/stocks"; + +/// Client for the Massive.com REST + WebSocket APIs. +#[derive(Clone)] +pub struct MarketClient { + http: reqwest::Client, + rest_base: String, + ws_url: String, + api_key: String, + /// When true, subscribe to per-trade updates; otherwise per-second aggregates. + per_tick_updates: bool, +} + +impl MarketClient { + pub fn new(api_key: impl Into, per_tick_updates: bool) -> Self { + Self { + http: reqwest::Client::new(), + rest_base: DEFAULT_REST_BASE.to_string(), + ws_url: DEFAULT_WS_URL.to_string(), + api_key: api_key.into(), + per_tick_updates, + } + } + + /// Override the REST/WS hosts (useful for tests or alternate feeds). + pub fn with_hosts(mut self, rest_base: impl Into, ws_url: impl Into) -> Self { + self.rest_base = rest_base.into(); + self.ws_url = ws_url.into(); + self + } + + async fn get_json Deserialize<'de>>(&self, url: &str) -> Result { + let resp = self + .http + .get(url) + .bearer_auth(&self.api_key) + .send() + .await + .with_context(|| format!("request failed: {url}"))? + .error_for_status() + .with_context(|| format!("non-success status: {url}"))?; + resp.json::() + .await + .with_context(|| format!("decode failed: {url}")) + } + + /// Assemble a full `Ticker` from previous close, last trade, and company details. + pub async fn load_ticker_data(&self, symbol: &str) -> Result { + let previous_close_price = self.get_yesterdays_close(symbol).await?; + let price = self.get_last_trade_price(symbol).await?; + let details = self.get_ticker_details(symbol).await?; + + Ok(Ticker { + symbol: symbol.to_string(), + company_name: details.company_name, + outstanding_shares: details.outstanding_shares, + price, + previous_close_price, + ..Default::default() + }) + } + + /// Latest weighted price for a ticker (`GET /v2/last/trade/{ticker}`). + pub async fn get_last_trade_price(&self, symbol: &str) -> Result { + let url = format!("{}/v2/last/trade/{symbol}", self.rest_base); + let resp: LastTradeResponse = self.get_json(&url).await?; + Ok(resp.results.price) + } + + /// Company name + shares outstanding (`GET /v3/reference/tickers/{ticker}`). + pub async fn get_ticker_details(&self, symbol: &str) -> Result { + let url = format!("{}/v3/reference/tickers/{symbol}", self.rest_base); + let resp: TickerDetailsResponse = self.get_json(&url).await?; + Ok(TickerDetails { + company_name: resp.results.name, + outstanding_shares: resp.results.weighted_shares_outstanding, + }) + } + + /// Previous trading day's close (`GET /v2/aggs/ticker/{ticker}/prev`). Accounts + /// for weekends/holidays server-side; returns 0.0 if the ticker has no history. + pub async fn get_yesterdays_close(&self, symbol: &str) -> Result { + let url = format!("{}/v2/aggs/ticker/{symbol}/prev", self.rest_base); + let resp: AggsResponse = self.get_json(&url).await?; + Ok(resp.results.first().map(|a| a.close).unwrap_or(0.0)) + } + + /// Intraday aggregates for the sparkline, `range_size`-minute bars from 09:00 to + /// 16:30 ET (09:00 start includes significant pre-market). Mirrors + /// `massive_client.GetTickerTodayAggs`. + pub async fn get_today_aggs( + &self, + day: NaiveDate, + symbol: &str, + range_size: i32, + ) -> Result> { + let open = New_York + .with_ymd_and_hms(day.year(), day.month(), day.day(), 9, 0, 0) + .single() + .context("invalid market open time")?; + let close = New_York + .with_ymd_and_hms(day.year(), day.month(), day.day(), 16, 30, 0) + .single() + .context("invalid market close time")?; + + let from_ms = open.timestamp_millis(); + let to_ms = close.timestamp_millis(); + let limit = (close - open).num_minutes().max(1); + + let url = format!( + "{}/v2/aggs/ticker/{symbol}/range/{range_size}/minute/{from_ms}/{to_ms}\ + ?adjusted=true&sort=asc&limit={limit}", + self.rest_base + ); + let resp: AggsResponse = self.get_json(&url).await?; + + Ok(resp + .results + .into_iter() + .map(|a| Agg { + price: a.close, + volume: a.volume as i32, + timestamp: a.timestamp, + }) + .collect()) + } + + /// Top market movers (gainers or losers) for US stocks. Returns them in the + /// API's order (biggest move first). `GET /v2/snapshot/locale/us/markets/ + /// stocks/{direction}`. + pub async fn get_market_movers(&self, direction: MoverDirection) -> Result> { + let url = format!( + "{}/v2/snapshot/locale/us/markets/stocks/{}", + self.rest_base, + direction.path() + ); + let resp: MoversResponse = self.get_json(&url).await?; + Ok(resp + .tickers + .into_iter() + .map(|t| { + // Prefer the last trade price; fall back to today's close. + let price = if t.last_trade.p != 0.0 { + t.last_trade.p + } else { + t.day.c + }; + Mover { + symbol: t.ticker, + price, + todays_change: t.todays_change, + todays_change_percentage: t.todays_change_perc, + } + }) + .collect()) + } +} + +/// Company metadata returned by [`MarketClient::get_ticker_details`]. +pub struct TickerDetails { + pub company_name: String, + pub outstanding_shares: i64, +} + +/// Most recent completed weekday at/behind the given date (walks back over +/// weekends). Holidays are handled server-side by the aggregates endpoint. +pub fn current_or_previous_weekday(mut day: NaiveDate) -> NaiveDate { + loop { + match day.weekday() { + Weekday::Sat | Weekday::Sun => { + day = day.pred_opt().expect("date underflow"); + } + _ => return day, + } + } +} + +/// The current trading day (ET), walking back over weekends. +pub fn latest_trading_day() -> NaiveDate { + let now_et = Utc::now().with_timezone(&New_York); + current_or_previous_weekday(now_et.date_naive()) +} + +// ---- REST response shapes (short JSON tags as returned by the API) -------- + +#[derive(Deserialize)] +struct AggsResponse { + #[serde(default)] + results: Vec, +} + +#[derive(Deserialize)] +struct AggResult { + #[serde(default, rename = "c")] + close: f64, + #[serde(default, rename = "v")] + volume: f64, + #[serde(default, rename = "t")] + timestamp: i64, +} + +#[derive(Deserialize)] +struct LastTradeResponse { + results: LastTradeResult, +} + +#[derive(Deserialize)] +struct LastTradeResult { + #[serde(default, rename = "p")] + price: f64, +} + +#[derive(Deserialize)] +struct MoversResponse { + #[serde(default)] + tickers: Vec, +} + +#[derive(Deserialize)] +struct MoverResult { + #[serde(default)] + ticker: String, + #[serde(default, rename = "todaysChange")] + todays_change: f64, + #[serde(default, rename = "todaysChangePerc")] + todays_change_perc: f64, + #[serde(default, rename = "lastTrade")] + last_trade: MoverTrade, + #[serde(default)] + day: MoverDay, +} + +#[derive(Deserialize, Default)] +struct MoverTrade { + #[serde(default)] + p: f64, +} + +#[derive(Deserialize, Default)] +struct MoverDay { + #[serde(default)] + c: f64, +} + +#[derive(Deserialize)] +struct TickerDetailsResponse { + results: TickerDetailsResult, +} + +#[derive(Deserialize)] +struct TickerDetailsResult { + #[serde(default)] + name: String, + #[serde(default)] + weighted_shares_outstanding: i64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn weekend_walks_back_to_friday() { + // 2026-07-25 is a Saturday; 2026-07-26 a Sunday. Both -> Fri 2026-07-24. + let sat = NaiveDate::from_ymd_opt(2026, 7, 25).unwrap(); + let sun = NaiveDate::from_ymd_opt(2026, 7, 26).unwrap(); + let fri = NaiveDate::from_ymd_opt(2026, 7, 24).unwrap(); + assert_eq!(current_or_previous_weekday(sat), fri); + assert_eq!(current_or_previous_weekday(sun), fri); + // A weekday is returned unchanged. + assert_eq!(current_or_previous_weekday(fri), fri); + } +} diff --git a/crates/data/src/ws.rs b/crates/data/src/ws.rs new file mode 100644 index 0000000..48e7e6c --- /dev/null +++ b/crates/data/src/ws.rs @@ -0,0 +1,148 @@ +//! WebSocket price feed. Authenticates, subscribes to per-trade or per-second +//! aggregate topics, and forwards each price tick onto an mpsc channel. Mirrors +//! `massive_client/ws.go`. + +use anyhow::{Context, Result}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::Value; +use tickerwall_proto::PriceUpdate; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message; +use tokio_util::sync::CancellationToken; + +use crate::MarketClient; + +impl MarketClient { + /// Connect, authenticate, subscribe to `symbols`, and forward live prices to + /// `tx` until `cancel` fires, the receiver is dropped, or the socket closes. + /// The caller is responsible for reconnect/retry. + pub async fn listen_for_price_updates( + &self, + symbols: &[String], + tx: mpsc::Sender, + cancel: CancellationToken, + ) -> Result<()> { + let (ws, _) = tokio_tungstenite::connect_async(&self.ws_url) + .await + .with_context(|| format!("websocket connect: {}", self.ws_url))?; + let (mut write, mut read) = ws.split(); + + // Authenticate immediately; subscribe once the server acks auth_success. + let auth = serde_json::json!({ "action": "auth", "params": self.api_key }).to_string(); + write.send(Message::Text(auth)).await.context("send auth")?; + + // "A" = per-second aggregates, "T" = individual trades. + let prefix = if self.per_tick_updates { "T" } else { "A" }; + let params = symbols + .iter() + .map(|s| format!("{prefix}.{s}")) + .collect::>() + .join(","); + let subscribe = serde_json::json!({ "action": "subscribe", "params": params }).to_string(); + + loop { + tokio::select! { + _ = cancel.cancelled() => { + let _ = write.send(Message::Close(None)).await; + return Ok(()); + } + msg = read.next() => { + let msg = match msg { + Some(m) => m.context("websocket read")?, + None => return Ok(()), // stream ended + }; + match msg { + Message::Text(text) => { + for event in parse_events(text.as_str()) { + if is_auth_success(&event) { + write + .send(Message::Text(subscribe.clone())) + .await + .context("send subscribe")?; + continue; + } + if let Some(update) = price_update_from_event(&event) { + if tx.send(update).await.is_err() { + return Ok(()); // receiver gone + } + } + } + } + Message::Ping(payload) => { + let _ = write.send(Message::Pong(payload)).await; + } + Message::Close(_) => return Ok(()), + _ => {} + } + } + } + } + } +} + +/// Split a server frame into individual events. Frames are usually a JSON array +/// of event objects, but tolerate a single object too. +fn parse_events(text: &str) -> Vec { + match serde_json::from_str::(text) { + Ok(Value::Array(events)) => events, + Ok(other) => vec![other], + Err(_) => Vec::new(), + } +} + +/// True if this is the control message telling us authentication succeeded. +fn is_auth_success(event: &Value) -> bool { + event.get("ev").and_then(Value::as_str) == Some("status") + && event.get("status").and_then(Value::as_str) == Some("auth_success") +} + +/// Extract a price update from a data event, if this event carries a price. +/// `A`/`AM` (aggregates) use close `c`; `T` (trade) uses price `p`. +fn price_update_from_event(event: &Value) -> Option { + let ev = event.get("ev").and_then(Value::as_str)?; + let symbol = event.get("sym").and_then(Value::as_str)?.to_string(); + let price = match ev { + "A" | "AM" => event.get("c").and_then(Value::as_f64)?, + "T" => event.get("p").and_then(Value::as_f64)?, + _ => return None, + }; + Some(PriceUpdate { symbol, price }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_second_aggregate() { + let events = parse_events(r#"[{"ev":"A","sym":"AAPL","c":211.34,"v":1000}]"#); + let update = price_update_from_event(&events[0]).unwrap(); + assert_eq!(update.symbol, "AAPL"); + assert_eq!(update.price, 211.34); + } + + #[test] + fn parses_trade() { + let events = parse_events(r#"[{"ev":"T","sym":"NVDA","p":123.45,"s":10}]"#); + let update = price_update_from_event(&events[0]).unwrap(); + assert_eq!(update.symbol, "NVDA"); + assert_eq!(update.price, 123.45); + } + + #[test] + fn recognizes_auth_success_and_ignores_status_events() { + let events = parse_events(r#"[{"ev":"status","status":"auth_success","message":"ok"}]"#); + assert!(is_auth_success(&events[0])); + assert!(price_update_from_event(&events[0]).is_none()); + } + + #[test] + fn handles_multiple_events_in_one_frame() { + let events = parse_events( + r#"[{"ev":"status","status":"connected"},{"ev":"A","sym":"MSFT","c":400.0}]"#, + ); + assert_eq!(events.len(), 2); + assert!(!is_auth_success(&events[0])); + assert_eq!(price_update_from_event(&events[1]).unwrap().price, 400.0); + } +} diff --git a/crates/data/tests/live_smoke.rs b/crates/data/tests/live_smoke.rs new file mode 100644 index 0000000..48ae3b2 --- /dev/null +++ b/crates/data/tests/live_smoke.rs @@ -0,0 +1,85 @@ +//! Live smoke tests against the real Massive.com API. Ignored by default so CI / +//! `cargo test` stay offline. Run manually with a key in the environment: +//! +//! TW_API_KEY=xxx cargo test -p tickerwall-data --test live_smoke -- --ignored --nocapture +//! +//! REST works any time; live WS prices only flow during market hours, so the WS +//! test just verifies the connect/auth/subscribe handshake succeeds. + +use std::time::Duration; + +use tickerwall_data::{latest_trading_day, MarketClient}; + +fn api_key() -> Option { + std::env::var("TW_API_KEY").ok().filter(|k| !k.is_empty()) +} + +#[tokio::test] +#[ignore = "hits the live API; requires TW_API_KEY"] +async fn rest_smoke() { + let Some(key) = api_key() else { + eprintln!("TW_API_KEY not set — skipping"); + return; + }; + let client = MarketClient::new(key, false); + + let ticker = client + .load_ticker_data("AAPL") + .await + .expect("load_ticker_data(AAPL)"); + eprintln!( + "AAPL -> name={:?} price={} prev_close={} shares={}", + ticker.company_name, ticker.price, ticker.previous_close_price, ticker.outstanding_shares + ); + assert!(!ticker.company_name.is_empty(), "expected a company name"); + assert!(ticker.previous_close_price > 0.0, "expected a prev close"); + + let aggs = client + .get_today_aggs(latest_trading_day(), "AAPL", 10) + .await + .expect("get_today_aggs(AAPL)"); + eprintln!( + "AAPL -> {} aggregate bars for {}", + aggs.len(), + latest_trading_day() + ); +} + +#[tokio::test] +#[ignore = "hits the live API; requires TW_API_KEY"] +async fn ws_handshake_smoke() { + let Some(key) = api_key() else { + eprintln!("TW_API_KEY not set — skipping"); + return; + }; + let client = MarketClient::new(key, false); + let (tx, mut rx) = tokio::sync::mpsc::channel(100); + let cancel = tokio_util::sync::CancellationToken::new(); + + let listener = { + let client = client.clone(); + let cancel = cancel.clone(); + tokio::spawn(async move { + client + .listen_for_price_updates(&["AAPL".into(), "MSFT".into()], tx, cancel) + .await + }) + }; + + // Either a live tick arrives (market open) or we time out (market closed). + // Either way, a clean return from the listener means auth+subscribe worked. + tokio::select! { + Some(update) = rx.recv() => { + eprintln!("live update: {} = {}", update.symbol, update.price); + } + _ = tokio::time::sleep(Duration::from_secs(6)) => { + eprintln!("no live data in 6s (market likely closed) — handshake OK if no error below"); + } + } + + cancel.cancel(); + listener + .await + .expect("listener task panicked") + .expect("websocket listener returned an error"); +} diff --git a/crates/gui/Cargo.toml b/crates/gui/Cargo.toml new file mode 100644 index 0000000..75b2e1b --- /dev/null +++ b/crates/gui/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "tickerwall-gui" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +tickerwall-proto.workspace = true +tickerwall-client.workspace = true +tokio.workspace = true +tokio-util.workspace = true +tracing.workspace = true +anyhow.workspace = true + +femtovg = "0.26" +winit = "0.30" +glutin = "0.32" +glutin-winit = "0.5" +raw-window-handle = "0.6" diff --git a/fonts/Roboto-Bold.ttf b/crates/gui/assets/fonts/Roboto-Bold.ttf similarity index 100% rename from fonts/Roboto-Bold.ttf rename to crates/gui/assets/fonts/Roboto-Bold.ttf diff --git a/fonts/Roboto-Light.ttf b/crates/gui/assets/fonts/Roboto-Light.ttf similarity index 100% rename from fonts/Roboto-Light.ttf rename to crates/gui/assets/fonts/Roboto-Light.ttf diff --git a/crates/gui/src/app.rs b/crates/gui/src/app.rs new file mode 100644 index 0000000..c10a7af --- /dev/null +++ b/crates/gui/src/app.rs @@ -0,0 +1,335 @@ +//! winit `ApplicationHandler` + glutin OpenGL context + the per-frame render loop. +//! The GUI main thread owns rendering; a background tokio task (the client) keeps +//! shared state fresh, which this loop reads every frame. + +use std::num::NonZeroU32; +use std::sync::Arc; +use std::time::Instant; + +use femtovg::{renderer::OpenGl, Canvas, Color}; +use glutin::config::{ConfigTemplateBuilder, GlConfig}; +use glutin::context::{ContextAttributesBuilder, NotCurrentGlContext, PossiblyCurrentContext}; +use glutin::display::{GetGlDisplay, GlDisplay}; +use glutin::surface::{GlSurface, Surface, SwapInterval, WindowSurface}; +use glutin_winit::{DisplayBuilder, GlWindow}; +use raw_window_handle::HasWindowHandle; +use tickerwall_client::{ClusterClient, GrpcStatus}; +use tracing::error; +use winit::application::ApplicationHandler; +use winit::event::WindowEvent; +use winit::event_loop::ActiveEventLoop; +use winit::window::{Window, WindowId}; + +use crate::draw; +use crate::fonts::Fonts; +use crate::layout; +use crate::notifications::Notifications; + +/// Everything created once the event loop is `resumed` (needs an active display). +struct RenderState { + window: Window, + surface: Surface, + context: PossiblyCurrentContext, + canvas: Canvas, + fonts: Fonts, + last_logical_size: (i32, i32), +} + +/// Rolling frames-per-second counter. Averages over ~half a second so the +/// on-screen readout is stable instead of flickering every frame. +struct FpsCounter { + last: Option, + accum_secs: f32, + accum_frames: u32, + display: f32, +} + +impl FpsCounter { + fn new() -> Self { + Self { + last: None, + accum_secs: 0.0, + accum_frames: 0, + display: 0.0, + } + } + + /// Record a frame boundary and return the current averaged FPS. + fn tick(&mut self) -> f32 { + let now = Instant::now(); + if let Some(last) = self.last { + self.accum_secs += now.duration_since(last).as_secs_f32(); + self.accum_frames += 1; + if self.accum_secs >= 0.5 { + self.display = self.accum_frames as f32 / self.accum_secs; + self.accum_secs = 0.0; + self.accum_frames = 0; + } + } + self.last = Some(now); + self.display + } +} + +pub struct App { + client: Arc, + notifications: Notifications, + state: Option, + fullscreen: bool, + fps: FpsCounter, +} + +impl App { + pub fn new(client: Arc, fullscreen: bool) -> Self { + Self { + client, + notifications: Notifications::new(), + state: None, + fullscreen, + fps: FpsCounter::new(), + } + } + + fn init(&mut self, event_loop: &ActiveEventLoop) { + let screen = self.client.screen(); + let mut attrs = Window::default_attributes() + .with_title(format!("Massive Ticker Wall ( INDEX: {} )", screen.index)) + .with_inner_size(winit::dpi::LogicalSize::new( + screen.width.max(1) as f64, + screen.height.max(1) as f64, + )); + if self.fullscreen { + // Borderless fullscreen on the current monitor (kiosk). + attrs = attrs.with_fullscreen(Some(winit::window::Fullscreen::Borderless(None))); + } + + let template = ConfigTemplateBuilder::new().with_alpha_size(8); + let display_builder = DisplayBuilder::new().with_window_attributes(Some(attrs)); + let (window, gl_config) = display_builder + .build(event_loop, template, |configs| { + configs + .reduce(|a, b| { + if b.num_samples() > a.num_samples() { + b + } else { + a + } + }) + .expect("no GL config") + }) + .expect("failed to build GL display"); + + let window = window.expect("no window created"); + let raw = window.window_handle().expect("window handle").as_raw(); + let gl_display = gl_config.display(); + + let context_attributes = ContextAttributesBuilder::new().build(Some(raw)); + let not_current = unsafe { + gl_display + .create_context(&gl_config, &context_attributes) + .expect("create GL context") + }; + + let surface_attrs = window + .build_surface_attributes(Default::default()) + .expect("surface attributes"); + let surface = unsafe { + gl_display + .create_window_surface(&gl_config, &surface_attrs) + .expect("create window surface") + }; + let context = not_current.make_current(&surface).expect("make current"); + + // vsync so we don't spin at thousands of fps. + let _ = + surface.set_swap_interval(&context, SwapInterval::Wait(NonZeroU32::new(1).unwrap())); + + let renderer = + unsafe { OpenGl::new_from_function_cstr(|s| gl_display.get_proc_address(s)) } + .expect("create femtovg renderer"); + let mut canvas = Canvas::new(renderer).expect("create canvas"); + let fonts = Fonts::load(&mut canvas).expect("load fonts"); + + self.state = Some(RenderState { + window, + surface, + context, + canvas, + fonts, + last_logical_size: (screen.width, screen.height), + }); + } + + fn render(&mut self) { + let Some(state) = self.state.as_mut() else { + return; + }; + + let physical = state.window.inner_size(); + let dpi = state.window.scale_factor() as f32; + let (pw, ph) = (physical.width.max(1), physical.height.max(1)); + let logical_w = (pw as f32 / dpi).round() as i32; + let logical_h = (ph as f32 / dpi).round() as i32; + + // Report size changes to the leader (debounced by "did it change"). + if (logical_w, logical_h) != state.last_logical_size { + self.client.request_resize(logical_w, logical_h); + state.last_logical_size = (logical_w, logical_h); + } + + state.canvas.set_size(pw, ph, dpi); + + // One lock acquisition for all shared state this frame. + let frame = self.client.render_snapshot(); + + // Always measure frame rate (cheap) so the meter is accurate the moment + // `show_fps` is toggled on. + let fps = self.fps.tick(); + + // Feed any newly-arrived announcements into the manager. + for announcement in frame.new_announcements { + self.notifications.add(announcement); + } + + let settings = frame.cluster.as_ref().and_then(|c| c.settings.as_ref()); + + // Background (config color if we have settings, else black). Clear the + // whole framebuffer in physical pixels, before the DPI transform below. + let bg = settings + .map(|s| draw::color_of(&s.bg_color, Color::black())) + .unwrap_or_else(Color::black); + state.canvas.clear_rect(0, 0, pw, ph, bg); + + // femtovg draws in physical pixels; our whole layout is in logical + // (density-independent) units. Scale by the device pixel ratio so logical + // coordinates fill the physical surface. Without this, everything renders + // at 1/dpi scale in the top-left on any HiDPI (e.g. Retina) display. + state.canvas.reset_transform(); + state.canvas.scale(dpi, dpi); + + if let (Some(settings), Some(cluster)) = (settings, frame.cluster.as_ref()) { + let global = layout::global_offset( + layout::now_nanos(), + settings.scroll_speed, + settings.ticker_box_width, + frame.tickers.len(), + ); + let screen_offset = cluster.screen_global_offset(&frame.screen.uuid); + + // Center the primary tape in the space above the movers strip (or the + // whole window when the strip is hidden), not the whole window. + let showing_movers = settings.show_movers && !frame.movers.is_empty(); + let content_height = if showing_movers { + frame.screen.height as f32 - draw::movers_tape_height(frame.screen.height as f32) + } else { + frame.screen.height as f32 + }; + + draw::render_tickers( + &mut state.canvas, + &state.fonts, + settings, + content_height, + &frame.tickers, + global, + screen_offset, + frame.screen.width as f32, + dpi, + ); + + // Secondary gainers/losers tape at the bottom, on its own speed. + if showing_movers { + let movers_global = layout::global_offset( + layout::now_nanos(), + settings.movers_scroll_speed, + draw::MOVERS_BOX_WIDTH, + frame.movers.len(), + ); + draw::render_movers_tape( + &mut state.canvas, + &state.fonts, + settings, + &frame.screen, + cluster, + &frame.movers, + movers_global, + screen_offset, + dpi, + ); + } + + self.notifications.render( + &mut state.canvas, + &state.fonts, + settings, + cluster, + &frame.screen, + ); + + // FPS meter on top of everything (incl. any notification banner). + if settings.show_fps { + draw::fps_meter( + &mut state.canvas, + &state.fonts, + frame.screen.height as f32, + fps, + ); + } + } + + // Overlay the system panel when not connected. + if frame.status != GrpcStatus::Connected { + let message = match frame.status { + GrpcStatus::Reconnecting => "Reconnecting to Leader..", + GrpcStatus::Disconnected => "Disconnected from Leader..", + GrpcStatus::Connected => "", + }; + draw::system_panel( + &mut state.canvas, + &state.fonts, + frame.screen.width as f32, + &frame.screen, + message, + ); + } + + state.canvas.flush_to_output(()); + let _ = state.surface.swap_buffers(&state.context); + } +} + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.state.is_none() { + self.init(event_loop); + } + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::Resized(size) => { + if let Some(state) = &self.state { + if let (Some(w), Some(h)) = + (NonZeroU32::new(size.width), NonZeroU32::new(size.height)) + { + state.surface.resize(&state.context, w, h); + } + } + } + WindowEvent::RedrawRequested => self.render(), + _ => {} + } + } + + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + if let Some(state) = &self.state { + state.window.request_redraw(); + } + } +} + +/// Log a client error without crashing the render loop. +pub(crate) fn log_client_error(err: anyhow::Error) { + error!(error = %err, "cluster client exited with error"); +} diff --git a/crates/gui/src/draw.rs b/crates/gui/src/draw.rs new file mode 100644 index 0000000..96e74cc --- /dev/null +++ b/crates/gui/src/draw.rs @@ -0,0 +1,436 @@ +//! Drawing routines: background, ticker boxes, the mini price graph, and the +//! system/status panel. Ports `gui/tickers.go` and `gui/system.go` onto femtovg. + +use femtovg::{Align, Baseline, Canvas, Color, Paint, Path, Renderer}; +use tickerwall_proto::{Mover, PresentationSettings, Rgba, Screen, ScreenCluster, Ticker}; + +use crate::fonts::Fonts; +use crate::layout::{self, VisibleTicker}; + +// Secondary "market movers" tape (pinned to the bottom of each screen). +/// Pixel width of one mover entry on the secondary tape (fixed, so entries have a +/// consistent rhythm and the dividers are evenly spaced). +pub const MOVERS_BOX_WIDTH: i32 = 620; +/// Fixed font size for mover entries (only shrinks to fit an unusually short +/// strip), so an entry looks the same regardless of screen height. +const MOVERS_FONT_SIZE: f32 = 46.0; +/// Tape height as a fraction of screen height, clamped to a sane pixel range. +const MOVERS_TAPE_HEIGHT_FRAC: f32 = 0.20; +const MOVERS_TAPE_MIN_H: f32 = 70.0; +const MOVERS_TAPE_MAX_H: f32 = 220.0; + +/// Height of the movers tape for a given screen height. +pub fn movers_tape_height(screen_height: f32) -> f32 { + (screen_height * MOVERS_TAPE_HEIGHT_FRAC).clamp(MOVERS_TAPE_MIN_H, MOVERS_TAPE_MAX_H) +} + +// Ticker box geometry. Sized so the box + movers strip both fit the 300px +// production screen height with margin (fonts scaled with the box height). +const TICKER_BOX_HEIGHT: f32 = 200.0; +const TICKER_BOX_MARGIN: f32 = 30.0; +const TICKER_BOX_PADDING: f32 = 50.0; +const TICKER_BOX_BORDER_RADIUS: f32 = 8.0; +const UPPER_ROW_FONT_SIZE: f32 = 80.0; +const BOTTOM_ROW_FONT_SIZE: f32 = 48.0; +const MAX_COMPANY_NAME_CHARS: usize = 14; + +// Full-bleed graph: opacity of the translucent area fill under the price line, +// and how far (as a fraction of box height) the line is kept off the top/bottom +// edges so it never clips into the box border. +const GRAPH_FILL_ALPHA: f32 = 0.42; +const GRAPH_VERTICAL_PAD: f32 = 0.12; + +pub fn color_of(c: &Option, fallback: Color) -> Color { + match c { + Some(r) => Color::rgba(r.red as u8, r.green as u8, r.blue as u8, r.alpha as u8), + None => fallback, + } +} + +/// Render every visible ticker for this screen. +/// `content_height` is the vertical space available to the primary tape (the +/// screen height minus the movers strip when it's shown); the ticker boxes are +/// centered within it rather than within the whole window. +#[allow(clippy::too_many_arguments)] +pub fn render_tickers( + canvas: &mut Canvas, + fonts: &Fonts, + settings: &PresentationSettings, + content_height: f32, + tickers: &[Ticker], + global_offset: f32, + screen_offset: f32, + window_width: f32, + dpi: f32, +) { + let visible = layout::visible_tickers( + global_offset, + screen_offset, + window_width, + tickers.len(), + settings.ticker_box_width, + ); + for VisibleTicker { index, x } in visible { + if let Some(ticker) = tickers.get(index) { + // Snap the box to the device-pixel grid so scrolling text keeps a + // constant glyph sub-pixel phase (no letter-spacing jitter). + let x = snap_to_pixel(x, dpi); + render_ticker(canvas, fonts, settings, content_height, ticker, x); + } + } +} + +/// Snap a logical coordinate so it lands on a whole device pixel. +fn snap_to_pixel(x: f32, dpi: f32) -> f32 { + (x * dpi).round() / dpi +} + +fn render_ticker_bg( + canvas: &mut Canvas, + settings: &PresentationSettings, + content_height: f32, + left_offset: f32, +) { + let top = (content_height / 2.0) - (TICKER_BOX_HEIGHT / 2.0); + let left = left_offset + (TICKER_BOX_MARGIN / 2.0); + let box_width = settings.ticker_box_width as f32 - TICKER_BOX_MARGIN; + + let mut path = Path::new(); + path.rounded_rect( + left, + top, + box_width, + TICKER_BOX_HEIGHT, + TICKER_BOX_BORDER_RADIUS, + ); + let bg = color_of(&settings.ticker_box_bg_color, Color::rgb(20, 20, 20)); + canvas.fill_path(&path, &Paint::color(bg)); +} + +fn render_ticker( + canvas: &mut Canvas, + fonts: &Fonts, + settings: &PresentationSettings, + content_height: f32, + ticker: &Ticker, + ticker_offset: f32, +) { + render_ticker_bg(canvas, settings, content_height, ticker_offset); + + // Box interior geometry (matches render_ticker_bg). + let box_top = (content_height / 2.0) - (TICKER_BOX_HEIGHT / 2.0); + let box_left = ticker_offset + (TICKER_BOX_MARGIN / 2.0); + let box_width = settings.ticker_box_width as f32 - TICKER_BOX_MARGIN; + + // Directional color drives both the change text and the graph. + let directional = if ticker.price_change_percentage < 0.0 { + color_of(&settings.down_color, Color::rgb(255, 51, 51)) + } else { + color_of(&settings.up_color, Color::rgb(51, 255, 51)) + }; + + // Full-bleed price graph filling the whole box, behind the text. + draw_graph( + canvas, + ticker, + box_left, + box_top, + box_width, + TICKER_BOX_HEIGHT, + directional, + ); + + // --- Text layer, drawn on top of the graph --- + let offset_left = ticker_offset + (TICKER_BOX_MARGIN / 2.0) + TICKER_BOX_PADDING; + let offset_right = + (ticker_offset + settings.ticker_box_width as f32 - TICKER_BOX_MARGIN) - TICKER_BOX_PADDING; + let upper_row_top = box_top + (TICKER_BOX_HEIGHT * 0.33); + let lower_row_top = box_top + (TICKER_BOX_HEIGHT * 0.66); + + let font_color = color_of(&settings.font_color, Color::white()); + + // Symbol (upper-left, bold). + let mut top_paint = Paint::color(font_color); + top_paint.set_font(&[fonts.bold]); + top_paint.set_font_size(UPPER_ROW_FONT_SIZE); + top_paint.set_text_baseline(Baseline::Middle); + top_paint.set_text_align(Align::Left); + let _ = canvas.fill_text(offset_left, upper_row_top, &ticker.symbol, &top_paint); + + // Price (upper-right, bold). + top_paint.set_text_align(Align::Right); + let price = format!("{:.2}", ticker.price); + let _ = canvas.fill_text(offset_right, upper_row_top, &price, &top_paint); + + // Company name (lower-left, light, truncated). + let mut name = ticker.company_name.clone(); + if name.chars().count() >= MAX_COMPANY_NAME_CHARS { + let truncated: String = name.chars().take(MAX_COMPANY_NAME_CHARS - 3).collect(); + name = format!("{truncated}..."); + } + let mut bottom_paint = Paint::color(font_color); + bottom_paint.set_font(&[fonts.light]); + bottom_paint.set_font_size(BOTTOM_ROW_FONT_SIZE); + bottom_paint.set_text_baseline(Baseline::Middle); + bottom_paint.set_text_align(Align::Left); + let _ = canvas.fill_text(offset_left, lower_row_top, &name, &bottom_paint); + + // Change: +diff (+pct%) lower-right. White — the graph behind the box already + // supplies the up/down color, so a colored value here is redundant. + let price_diff = ticker.price - ticker.previous_close_price; + let change = format!( + "{:+.2} ({:+.2}%)", + price_diff, ticker.price_change_percentage + ); + let mut change_paint = Paint::color(font_color); + change_paint.set_font(&[fonts.light]); + change_paint.set_font_size(BOTTOM_ROW_FONT_SIZE); + change_paint.set_text_baseline(Baseline::Middle); + change_paint.set_text_align(Align::Right); + let _ = canvas.fill_text(offset_right, lower_row_top, &change, &change_paint); +} + +/// Full-bleed price graph filling the whole ticker box, drawn behind the text: +/// a translucent area fill under a solid price line, plus an endpoint dot. Prices +/// are normalized to the aggregate min/max so the line uses the full box height. +#[allow(clippy::too_many_arguments)] +fn draw_graph( + canvas: &mut Canvas, + ticker: &Ticker, + box_left: f32, + box_top: f32, + box_width: f32, + box_height: f32, + color: Color, +) { + let points = ticker.aggs.len(); + if points < 2 { + return; + } + + // Inset by the corner radius so the fill never spills past the box's rounded + // corners (avoids needing a clip/scissor). + let inset = TICKER_BOX_BORDER_RADIUS; + let x0 = box_left + inset; + let y0 = box_top + inset; + let w = box_width - inset * 2.0; + let h = box_height - inset * 2.0; + let bottom = y0 + h; + + let mut min = f32::INFINITY; + let mut max = f32::NEG_INFINITY; + for a in &ticker.aggs { + let p = a.price as f32; + min = min.min(p); + max = max.max(p); + } + let range = (max - min).max(f32::EPSILON); + + // Keep the line off the very top/bottom edges. + let v_pad = h * GRAPH_VERTICAL_PAD; + let usable_h = h - v_pad * 2.0; + let dx = w / (points as f32 - 1.0); + let xy = |i: usize| -> (f32, f32) { + let norm = (ticker.aggs[i].price as f32 - min) / range; // 0 at low, 1 at high + (x0 + i as f32 * dx, y0 + v_pad + (1.0 - norm) * usable_h) + }; + + // Translucent area under the line (line points, then down to the baseline). + let (sx, sy) = xy(0); + let mut area = Path::new(); + area.move_to(sx, sy); + for i in 1..points { + let (x, y) = xy(i); + area.line_to(x, y); + } + let (ex, _) = xy(points - 1); + area.line_to(ex, bottom); + area.line_to(x0, bottom); + area.close(); + let mut fill_color = color; + fill_color.a = GRAPH_FILL_ALPHA; + canvas.fill_path(&area, &Paint::color(fill_color)); +} + +/// Small FPS readout pinned to the top-left corner. Shown only when the +/// `show_fps` presentation setting is enabled. Sizes itself off the screen +/// height so it stays legible from a 300px dev window up to a 1080p wall. +pub fn fps_meter(canvas: &mut Canvas, fonts: &Fonts, screen_height: f32, fps: f32) { + let font_size = (screen_height * 0.06).clamp(20.0, 64.0); + let pad = font_size * 0.4; + let text = format!("{fps:.0} FPS"); + let box_w = font_size * 5.0; + let box_h = font_size + pad * 2.0; + + let mut path = Path::new(); + path.rounded_rect(pad, pad, box_w, box_h, 6.0); + canvas.fill_path(&path, &Paint::color(Color::rgba(0, 0, 0, 160))); + + let mut paint = Paint::color(Color::rgb(0, 255, 0)); + paint.set_font(&[fonts.bold]); + paint.set_font_size(font_size); + paint.set_text_align(Align::Left); + paint.set_text_baseline(Baseline::Middle); + let _ = canvas.fill_text(pad * 2.0, pad + box_h / 2.0, &text, &paint); +} + +/// Render the secondary gainers/losers tape pinned to the bottom of the screen. +/// Like the primary tape it spans the whole cluster (so it's continuous across +/// screens) but scrolls at its own speed and shows a compact `SYMBOL price +x%` +/// entry per mover, colored green/red by direction. +#[allow(clippy::too_many_arguments)] +pub fn render_movers_tape( + canvas: &mut Canvas, + fonts: &Fonts, + settings: &PresentationSettings, + screen: &Screen, + _cluster: &ScreenCluster, + movers: &[Mover], + global_offset: f32, + screen_offset: f32, + dpi: f32, +) { + if movers.is_empty() { + return; + } + let width = screen.width as f32; + let tape_height = movers_tape_height(screen.height as f32); + let strip_top = screen.height as f32 - tape_height; + + // Strip background + a subtle top divider. + let mut bg = Path::new(); + bg.rect(0.0, strip_top, width, tape_height); + canvas.fill_path(&bg, &Paint::color(Color::rgba(0, 0, 0, 190))); + let mut divider = Path::new(); + divider.rect(0.0, strip_top, width, 2.0); + canvas.fill_path(÷r, &Paint::color(Color::rgba(255, 255, 255, 40))); + + let visible = layout::visible_tickers( + global_offset, + screen_offset, + width, + movers.len(), + MOVERS_BOX_WIDTH, + ); + for VisibleTicker { index, x } in visible { + if let Some(mover) = movers.get(index) { + // Same device-pixel snap as the primary tape so the text doesn't jitter. + let x = snap_to_pixel(x, dpi); + draw_mover(canvas, fonts, settings, mover, x, strip_top, tape_height); + } + } +} + +fn draw_mover( + canvas: &mut Canvas, + fonts: &Fonts, + settings: &PresentationSettings, + mover: &Mover, + x: f32, + strip_top: f32, + tape_height: f32, +) { + let mid_y = strip_top + tape_height / 2.0; + // Fixed font size (only shrinks on an unusually short strip) keeps every entry + // the same regardless of screen height. + let font_size = MOVERS_FONT_SIZE.min(tape_height * 0.5); + let gap = font_size * 0.5; + + // Divider at this entry's left edge, separating it from the previous one. + let div_margin = tape_height * 0.24; + let mut divider = Path::new(); + divider.rect( + x, + strip_top + div_margin, + 3.0, + tape_height - div_margin * 2.0, + ); + canvas.fill_path(÷r, &Paint::color(Color::rgba(255, 255, 255, 60))); + + let dir_color = if mover.todays_change_percentage < 0.0 { + color_of(&settings.down_color, Color::rgb(255, 51, 51)) + } else { + color_of(&settings.up_color, Color::rgb(51, 255, 51)) + }; + let font_color = color_of(&settings.font_color, Color::white()); + + // Tight group: SYMBOL price change%, centered within the fixed-width cell so + // the left and right margins (to the dividers) are equal. Measure all three + // first, then place them at a running cursor. + let price = format!("${:.2}", mover.price); + let change = format!("{:+.2}%", mover.todays_change_percentage); + let gap_sym = gap * 1.3; + + let mut sym = Paint::color(font_color); + sym.set_font(&[fonts.bold]); + sym.set_font_size(font_size); + sym.set_text_baseline(Baseline::Middle); + sym.set_text_align(Align::Left); + + let mut pr = Paint::color(font_color); + pr.set_font(&[fonts.light]); + pr.set_font_size(font_size); + pr.set_text_baseline(Baseline::Middle); + pr.set_text_align(Align::Left); + + let mut chg = Paint::color(dir_color); + chg.set_font(&[fonts.bold]); + chg.set_font_size(font_size); + chg.set_text_baseline(Baseline::Middle); + chg.set_text_align(Align::Left); + + let sym_w = measure_w(canvas, &mover.symbol, &sym); + let price_w = measure_w(canvas, &price, &pr); + let change_w = measure_w(canvas, &change, &chg); + let total = sym_w + gap_sym + price_w + gap + change_w; + + let mut cursor = x + (MOVERS_BOX_WIDTH as f32 - total) / 2.0; + let _ = canvas.fill_text(cursor, mid_y, &mover.symbol, &sym); + cursor += sym_w + gap_sym; + let _ = canvas.fill_text(cursor, mid_y, &price, &pr); + cursor += price_w + gap; + let _ = canvas.fill_text(cursor, mid_y, &change, &chg); +} + +/// Advance width of `text` under `paint`, for laying out text left-to-right. +fn measure_w(canvas: &mut Canvas, text: &str, paint: &Paint) -> f32 { + canvas + .measure_text(0.0, 0.0, text, paint) + .map(|m| m.width()) + .unwrap_or(0.0) +} + +/// Centered red panel shown when the client is not connected to the leader. +pub fn system_panel( + canvas: &mut Canvas, + fonts: &Fonts, + window_width: f32, + screen: &Screen, + message: &str, +) { + let panel_height = 200.0f32; + let padding = 20.0f32; + let from_top = (screen.height as f32 / 2.0) - (panel_height / 2.0); + + let mut path = Path::new(); + path.rounded_rect( + padding, + from_top, + window_width - (padding * 2.0), + panel_height, + 5.0, + ); + canvas.fill_path(&path, &Paint::color(Color::rgba(255, 0, 0, 222))); + + let mut text = Paint::color(Color::rgba(255, 255, 255, 255)); + text.set_font(&[fonts.bold]); + text.set_font_size(32.0); + text.set_text_align(Align::Center); + text.set_text_baseline(Baseline::Middle); + let _ = canvas.fill_text( + screen.width as f32 / 2.0, + screen.height as f32 / 2.0, + message, + &text, + ); +} diff --git a/crates/gui/src/ease.rs b/crates/gui/src/ease.rs new file mode 100644 index 0000000..8550a92 --- /dev/null +++ b/crates/gui/src/ease.rs @@ -0,0 +1,83 @@ +//! Easing functions (normalized Penner equations, input/output in `[0, 1]`). +//! These replace the Go `fogleman/ease` dependency; only the variants the +//! announcement animations use are implemented. + +use std::f32::consts::PI; + +const C1: f32 = 1.70158; +const C3: f32 = C1 + 1.0; +const C4: f32 = (2.0 * PI) / 3.0; + +pub fn in_quint(t: f32) -> f32 { + t * t * t * t * t +} + +pub fn out_quint(t: f32) -> f32 { + 1.0 - (1.0 - t).powi(5) +} + +pub fn in_back(t: f32) -> f32 { + C3 * t * t * t - C1 * t * t +} + +pub fn out_back(t: f32) -> f32 { + 1.0 + C3 * (t - 1.0).powi(3) + C1 * (t - 1.0).powi(2) +} + +pub fn in_elastic(t: f32) -> f32 { + if t == 0.0 { + 0.0 + } else if t == 1.0 { + 1.0 + } else { + -(2.0_f32.powf(10.0 * t - 10.0)) * ((t * 10.0 - 10.75) * C4).sin() + } +} + +pub fn out_elastic(t: f32) -> f32 { + if t == 0.0 { + 0.0 + } else if t == 1.0 { + 1.0 + } else { + 2.0_f32.powf(-10.0 * t) * ((t * 10.0 - 0.75) * C4).sin() + 1.0 + } +} + +pub fn out_bounce(t: f32) -> f32 { + const N1: f32 = 7.5625; + const D1: f32 = 2.75; + if t < 1.0 / D1 { + N1 * t * t + } else if t < 2.0 / D1 { + let t = t - 1.5 / D1; + N1 * t * t + 0.75 + } else if t < 2.5 / D1 { + let t = t - 2.25 / D1; + N1 * t * t + 0.9375 + } else { + let t = t - 2.625 / D1; + N1 * t * t + 0.984375 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoints_are_zero_and_one() { + for f in [ + in_quint as fn(f32) -> f32, + out_quint, + in_back, + out_back, + in_elastic, + out_elastic, + out_bounce, + ] { + assert!((f(0.0)).abs() < 1e-4, "f(0) should be ~0"); + assert!((f(1.0) - 1.0).abs() < 1e-4, "f(1) should be ~1"); + } + } +} diff --git a/crates/gui/src/fonts.rs b/crates/gui/src/fonts.rs new file mode 100644 index 0000000..96a4d3d --- /dev/null +++ b/crates/gui/src/fonts.rs @@ -0,0 +1,28 @@ +//! Embedded Roboto fonts, registered into the femtovg canvas. The TTF assets live +//! in the crate (`crates/gui/assets/fonts/`) and are embedded at compile time. + +use anyhow::{anyhow, Result}; +use femtovg::{Canvas, FontId, Renderer}; + +/// Handles to the font weights used by the renderer (light for body text, bold +/// for symbols/prices/announcements — matching the Go app's usage). +#[derive(Clone, Copy)] +pub struct Fonts { + pub light: FontId, + pub bold: FontId, +} + +const LIGHT: &[u8] = include_bytes!("../assets/fonts/Roboto-Light.ttf"); +const BOLD: &[u8] = include_bytes!("../assets/fonts/Roboto-Bold.ttf"); + +impl Fonts { + pub fn load(canvas: &mut Canvas) -> Result { + let light = canvas + .add_font_mem(LIGHT) + .map_err(|e| anyhow!("load light font: {e:?}"))?; + let bold = canvas + .add_font_mem(BOLD) + .map_err(|e| anyhow!("load bold font: {e:?}"))?; + Ok(Self { light, bold }) + } +} diff --git a/crates/gui/src/layout.rs b/crates/gui/src/layout.rs new file mode 100644 index 0000000..cecf553 --- /dev/null +++ b/crates/gui/src/layout.rs @@ -0,0 +1,172 @@ +//! Scroll / layout math — the load-bearing logic that makes N screens render one +//! continuous tape. Pure functions (no rendering) so they can be unit-tested. +//! +//! Ported from the Go `generateGlobalOffset` (gui.go) and +//! `DetermineTickersForRender` / `TickerOffset` (layout.go), with the acknowledged +//! coverage bug fixed: when a screen is wider than the whole tape, we keep wrapping +//! and drawing repeated copies to fill it instead of emitting `nil` tickers. + +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Wall-clock nanoseconds. Wall clock (not a per-process instant) is what keeps +/// every screen — even on different machines — scrolling in agreement. +pub fn now_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() +} + +/// The global scroll offset (in pixels) at `now_nanos`, reduced into `[0, tape)` +/// where `tape = num_tickers * ticker_box_width`. Higher `scroll_speed` scrolls +/// slower (it is inverted, matching the Go behavior: 1 is fastest). +pub fn global_offset( + now_nanos: u128, + scroll_speed: i32, + ticker_box_width: i32, + num_tickers: usize, +) -> f32 { + let tape = num_tickers as f64 * ticker_box_width as f64; + if tape <= 0.0 { + return 0.0; + } + // time.Millisecond == 1_000_000 ns; divisor = scroll_speed * 1ms. + let speed = scroll_speed.max(1) as f64; + let raw = now_nanos as f64 / (speed * 1_000_000.0); + let reduced = raw - (raw / tape).floor() * tape; + reduced as f32 +} + +/// A ticker that should be drawn this frame: its index into the sorted ticker +/// slice, and the x pixel offset of its box's left edge on this screen. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VisibleTicker { + pub index: usize, + pub x: f32, +} + +/// Determine which tickers are visible on a screen and where to draw them. +/// +/// * `global_offset` — from [`global_offset`]. +/// * `screen_offset` — this screen's left edge in tape coordinates +/// (`ScreenCluster::screen_global_offset`). +/// * `screen_width` — this screen's width in pixels. +/// +/// Returns entries left-to-right. If the screen is wider than the tape, tickers +/// wrap and repeat (each repeat gets its own x), so the screen is always filled. +pub fn visible_tickers( + global_offset: f32, + screen_offset: f32, + screen_width: f32, + num_tickers: usize, + ticker_box_width: i32, +) -> Vec { + let mut out = Vec::new(); + if num_tickers == 0 || ticker_box_width <= 0 { + return out; + } + let box_w = ticker_box_width as f32; + let n = num_tickers as i64; + let tape = n as f32 * box_w; + + // This screen's window into the tape, reduced into [0, tape). + let mut localized = (global_offset + screen_offset) % tape; + if localized < 0.0 { + localized += tape; + } + + // Leftmost (possibly partially visible) box index. + let first = (localized / box_w).floor() as i64; + + // Safety cap: at most every box once, plus a full extra wrap for wide screens. + let max_boxes = num_tickers * 2 + 4; + let mut i = first; + loop { + let x = (i as f32 * box_w) - localized; + if x >= screen_width { + break; + } + let index = ((i % n) + n) % n; // wrap into [0, n) + out.push(VisibleTicker { + index: index as usize, + x, + }); + i += 1; + if out.len() >= max_boxes { + break; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn global_offset_is_bounded_and_zero_safe() { + // No tickers -> no division, offset 0. + assert_eq!(global_offset(123_456_789, 8, 1100, 0), 0.0); + // Always within [0, tape). + let tape = 6.0 * 1100.0; + for t in [0u128, 1_000_000, 9_999_999_999, 123_456_789_012_345] { + let o = global_offset(t, 8, 1100, 6); + assert!(o >= 0.0 && o < tape, "offset {o} out of range for t={t}"); + } + } + + #[test] + fn visible_from_left_edge() { + // 6 tickers of width 1000, screen 2500 wide, aligned at 0. + let v = visible_tickers(0.0, 0.0, 2500.0, 6, 1000); + // boxes at x = 0, 1000, 2000 are visible (2500 wide -> 3 boxes). + assert_eq!(v.len(), 3); + assert_eq!(v[0], VisibleTicker { index: 0, x: 0.0 }); + assert_eq!( + v[1], + VisibleTicker { + index: 1, + x: 1000.0 + } + ); + assert_eq!( + v[2], + VisibleTicker { + index: 2, + x: 2000.0 + } + ); + } + + #[test] + fn second_screen_offset_shows_later_tickers() { + // Screen 2 sits 2000px into the tape. + let v = visible_tickers(0.0, 2000.0, 2000.0, 6, 1000); + assert_eq!(v[0].index, 2); + assert_eq!(v[1].index, 3); + } + + #[test] + fn wraps_around_the_end_of_the_tape() { + // Localized near the end of a 6*1000 tape should wrap back to index 0. + let v = visible_tickers(5500.0, 0.0, 1000.0, 6, 1000); + // localized = 5500 -> first box index 5 at x=-500, next index 0 at x=500. + assert_eq!(v[0].index, 5); + assert_eq!(v[1].index, 0); + } + + #[test] + fn screen_wider_than_tape_repeats_tickers_no_gaps() { + // Tape is 3*500 = 1500 wide; screen is 4000 wide. Must fill with repeats, + // never emit gaps (this is the Go "nil ticker" bug, fixed). + let v = visible_tickers(0.0, 0.0, 4000.0, 3, 500); + // 4000/500 = 8 boxes fill the screen. + assert_eq!(v.len(), 8); + // Indices cycle 0,1,2,0,1,2,0,1 and x increments by 500 with no gaps. + let expected_idx = [0, 1, 2, 0, 1, 2, 0, 1]; + for (k, vt) in v.iter().enumerate() { + assert_eq!(vt.index, expected_idx[k]); + assert_eq!(vt.x, k as f32 * 500.0); + } + } +} diff --git a/crates/gui/src/lib.rs b/crates/gui/src/lib.rs new file mode 100644 index 0000000..8f4388a --- /dev/null +++ b/crates/gui/src/lib.rs @@ -0,0 +1,69 @@ +//! The rendering layer: a winit + glutin window driving a femtovg render loop +//! that draws the scrolling tape, mini price graphs, notifications, and the +//! system panel. Replaces the Go `gui`, `gui/notifications`, and `fonts` packages. + +mod app; +mod draw; +mod ease; +mod fonts; +mod layout; +mod notifications; + +use std::sync::Arc; + +use anyhow::Result; +use tickerwall_client::{ClusterClient, Config as ClientConfig}; +use tokio_util::sync::CancellationToken; +use winit::event_loop::{ControlFlow, EventLoop}; + +/// GUI configuration. +pub struct Config { + pub leader: String, + pub screen_width: i32, + pub screen_height: i32, + pub screen_index: i32, + /// Borderless-fullscreen on the current monitor (for kiosk deployment). + pub fullscreen: bool, +} + +/// Run the GUI. Spins up a tokio runtime for the cluster client, then runs the +/// winit event loop on the calling (main) thread. Returns when the window closes. +pub fn run(config: Config) -> Result<()> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + + let fullscreen = config.fullscreen; + let client = ClusterClient::new(ClientConfig { + leader: config.leader, + screen_width: config.screen_width, + screen_height: config.screen_height, + screen_index: config.screen_index, + }); + + let cancel = CancellationToken::new(); + + // Background: keep local state synced with the leader. + { + let client = Arc::clone(&client); + let cancel = cancel.clone(); + runtime.spawn(async move { + if let Err(e) = client.run(cancel).await { + app::log_client_error(e); + } + }); + } + + // Foreground: the render loop (must own the main thread for OpenGL). + let event_loop = EventLoop::new()?; + event_loop.set_control_flow(ControlFlow::Poll); + let mut application = app::App::new(client, fullscreen); + let result = event_loop.run_app(&mut application); + + // Tear down the background client. + cancel.cancel(); + // Keep the runtime alive briefly for a graceful shutdown, then drop it. + runtime.shutdown_timeout(std::time::Duration::from_millis(200)); + + result.map_err(Into::into) +} diff --git a/crates/gui/src/notifications.rs b/crates/gui/src/notifications.rs new file mode 100644 index 0000000..652081e --- /dev/null +++ b/crates/gui/src/notifications.rs @@ -0,0 +1,163 @@ +//! Full-screen announcement overlays with eased slide in/out. Ports +//! `gui/notifications/`. The banner spans the whole cluster so its text lands in +//! the true center across all screens. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use femtovg::{Align, Baseline, Canvas, Color, Paint, Path, Renderer}; +use tickerwall_proto::{Announcement, PresentationSettings, Screen, ScreenCluster}; + +use crate::ease; +use crate::fonts::Fonts; + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +type EaseFn = fn(f32) -> f32; + +struct Notification { + announcement: Announcement, + completed: bool, + anim_in: EaseFn, + anim_out: EaseFn, +} + +impl Notification { + fn new(announcement: Announcement) -> Self { + // Match the Go pairing of intro/outro easings per animation type. + let (anim_out, anim_in): (EaseFn, EaseFn) = match announcement.animation { + // Bounce: bouncy on the way in, but a single springy wind-up on the way + // out. The Go original used `in_elastic` here, whose multiple sine + // oscillations made the banner visibly jitter up/down as it left; + // `in_back` winds up once and slides off cleanly. + 1 => (ease::in_back, ease::out_bounce), // Bounce + 2 => (ease::in_quint, ease::out_quint), // Ease + 3 => (ease::in_back, ease::out_back), // Back + _ => (ease::in_elastic, ease::out_elastic), // Elastic (default; oscillation is expected here) + }; + Self { + announcement, + completed: false, + anim_in, + anim_out, + } + } + + /// Whether to draw this frame; flips `completed` once fully expired. + fn should_render(&mut self, animation_duration_ms: i32) -> bool { + let now = now_ms(); + let show_at = self.announcement.show_at_timestamp_ms; + let end = show_at + self.announcement.lifespan_ms + animation_duration_ms as i64; + if now < show_at { + false + } else if now > end { + self.completed = true; + false + } else { + true + } + } + + fn render( + &self, + canvas: &mut Canvas, + fonts: &Fonts, + cluster: &ScreenCluster, + screen: &Screen, + animation_duration_ms: i32, + ) { + let now = now_ms(); + let anim_dur = animation_duration_ms.max(1) as f64; + let h = screen.height as f64; + + // Rest the text at the true vertical center of the (full-height) banner. + // The Go original hardcoded 140, which only looked centered on its default + // ~300px screens; deriving it from height keeps it centered at any size. + // Baseline::Middle means this y is the text's vertical midpoint. + let text_top_end = h / 2.0; + // Start off the top of the screen so it slides down into place. + let text_top_start = -(h / 2.0) - 160.0; + let bg_bottom_start = 0.0; + let bg_bottom_end = h; + + let mut text_top = text_top_end; + let mut bg_bottom = bg_bottom_end; + + let show_at = self.announcement.show_at_timestamp_ms; + let lifespan = self.announcement.lifespan_ms; + + if now - show_at < anim_dur as i64 { + // Enter animation. + let perc = (now - show_at) as f64 / anim_dur; + let inp = (self.anim_in)(perc as f32) as f64; + bg_bottom = bg_bottom_start - (bg_bottom_start - bg_bottom_end) * inp; + text_top = text_top_start - (text_top_start - text_top_end) * inp; + } else if now > show_at + lifespan { + // Exit animation. + let perc = (now - (show_at + lifespan)) as f64 / anim_dur; + let outp = (self.anim_out)(perc as f32) as f64; + bg_bottom = bg_bottom_end - (bg_bottom_end - bg_bottom_start) * outp; + text_top = text_top_end - (text_top_end - text_top_start) * outp; + } + let bg_top = bg_bottom - h; + + let screen_offset = cluster.screen_global_offset(&screen.uuid); + let left = -screen_offset; + let cluster_width = cluster.global_viewport_size() as f32; + + let mut path = Path::new(); + path.rect(left, bg_top as f32, cluster_width, bg_bottom as f32); + let bg = match self.announcement.r#type { + 1 => Color::rgba(255, 122, 122, 222), // danger + 2 => Color::rgba(122, 255, 122, 222), // success + _ => Color::rgba(122, 122, 255, 222), // info + }; + canvas.fill_path(&path, &Paint::color(bg)); + + let mut text = Paint::color(Color::rgba(255, 255, 255, 255)); + text.set_font(&[fonts.bold]); + text.set_font_size(96.0); + text.set_text_align(Align::Center); + text.set_text_baseline(Baseline::Middle); + let middle = (cluster_width / 2.0) - screen_offset; + let _ = canvas.fill_text(middle, text_top as f32, &self.announcement.message, &text); + } +} + +/// Owns the active announcement overlays. +#[derive(Default)] +pub struct Notifications { + items: Vec, +} + +impl Notifications { + pub fn new() -> Self { + Self::default() + } + + pub fn add(&mut self, announcement: Announcement) { + self.items.push(Notification::new(announcement)); + } + + /// Update, draw, and garbage-collect the active notifications. + pub fn render( + &mut self, + canvas: &mut Canvas, + fonts: &Fonts, + settings: &PresentationSettings, + cluster: &ScreenCluster, + screen: &Screen, + ) { + let anim_dur = settings.animation_duration_ms; + for n in &mut self.items { + if n.should_render(anim_dur) { + n.render(canvas, fonts, cluster, screen, anim_dur); + } + } + self.items.retain(|n| !n.completed); + } +} diff --git a/crates/leader/Cargo.toml b/crates/leader/Cargo.toml new file mode 100644 index 0000000..11bd859 --- /dev/null +++ b/crates/leader/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "tickerwall-leader" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +tickerwall-proto.workspace = true +tickerwall-data.workspace = true +tonic.workspace = true +prost.workspace = true +tokio.workspace = true +tokio-util.workspace = true +futures.workspace = true +async-stream.workspace = true +parking_lot.workspace = true +tracing.workspace = true +anyhow.workspace = true diff --git a/crates/leader/src/lib.rs b/crates/leader/src/lib.rs new file mode 100644 index 0000000..7d101c9 --- /dev/null +++ b/crates/leader/src/lib.rs @@ -0,0 +1,334 @@ +//! The authoritative cluster state, data-refresh loops, broadcast bus, and the +//! tonic `Leader` service. Replaces the Go `leader` + `server` packages. + +mod service; +mod state; + +pub use state::{Config, LeaderState}; + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use parking_lot::Mutex; +use tickerwall_data::{latest_trading_day, MarketClient, MoverDirection}; +use tickerwall_proto::{MarketMovers, PriceUpdate, Ticker, Update, UpdateKind as Kind}; +use tokio::sync::{broadcast, mpsc, Notify}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; + +/// Broadcast bus capacity. Slow clients that fall this far behind get a `Lagged` +/// error and skip ahead rather than blocking the bus. +const BROADCAST_CAPACITY: usize = 1024; +/// Aggregate bar size, in minutes. The Go original used 10, giving only ~45 +/// points across a full session — visibly jagged on the full-bleed graph. At 2 +/// minutes we get ~225 points: smooth without being noisy (1 minute was too busy). +pub(crate) const AGG_RANGE_MINUTES: i32 = 2; +/// How often intraday aggregates are refreshed. +pub const TICKER_AGGS_REFRESH_INTERVAL: Duration = Duration::from_secs(60); +/// How often company details / prices are refreshed (a true 5 minutes; the Go +/// original said "5min" in a comment but actually used 500s). +pub const TICKER_DETAILS_REFRESH_INTERVAL: Duration = Duration::from_secs(300); +/// How often the top gainers/losers tape is refreshed (they shift through the day). +pub const MOVERS_REFRESH_INTERVAL: Duration = Duration::from_secs(60); +/// How many gainers and how many losers to show on the secondary tape (each). +pub(crate) const MOVERS_PER_DIRECTION: usize = 10; +/// Lead time added to an announcement's display timestamp so every screen shows +/// it in sync. +pub(crate) const ANNOUNCEMENT_LEAD_MS: i64 = 200; +/// Backoff before reconnecting the price feed after an error/close. +const PRICE_FEED_RETRY: Duration = Duration::from_secs(2); + +/// The ticker wall leader. +pub struct Leader { + state: Arc>, + tx: broadcast::Sender, + data: MarketClient, + /// Notified when the ticker set changes so the price feed re-subscribes. + resubscribe: Arc, +} + +impl Leader { + pub fn new(config: Config) -> Self { + let data = MarketClient::new(config.api_key, config.settings.per_tick_updates); + let tickers = config + .tickers + .into_iter() + .map(|symbol| Ticker { + symbol, + ..Default::default() + }) + .collect(); + let state = LeaderState { + settings: config.settings, + tickers, + screens: Vec::new(), + movers: Vec::new(), + }; + let (tx, _) = broadcast::channel(BROADCAST_CAPACITY); + Self { + state: Arc::new(Mutex::new(state)), + tx, + data, + resubscribe: Arc::new(Notify::new()), + } + } + + /// Send an update to every connected client. An error only means no clients + /// are currently subscribed, which is fine. + fn broadcast(&self, update: Update) { + let _ = self.tx.send(update); + } + + fn symbols(&self) -> Vec { + self.state.lock().symbols() + } + + /// Synchronously load details + aggregates before accepting clients so the + /// first screen to join sees populated data. + pub async fn load_initial_data(&self) -> anyhow::Result<()> { + info!("loading initial ticker data…"); + self.refresh_details(true).await?; + self.refresh_aggs().await?; + // Movers are best-effort: a failure here shouldn't block startup. + if let Err(e) = self.refresh_movers().await { + warn!(error = %e, "initial movers load failed"); + } + info!("initial ticker data loaded"); + Ok(()) + } + + /// Fetch top gainers + losers and broadcast them as the secondary tape. + async fn refresh_movers(&self) -> anyhow::Result<()> { + let gainers = self.data.get_market_movers(MoverDirection::Gainers).await?; + let losers = self.data.get_market_movers(MoverDirection::Losers).await?; + + let mut movers = Vec::with_capacity(MOVERS_PER_DIRECTION * 2); + movers.extend(gainers.into_iter().take(MOVERS_PER_DIRECTION)); + movers.extend(losers.into_iter().take(MOVERS_PER_DIRECTION)); + + self.state.lock().movers = movers.clone(); + self.broadcast(Update { + kind: Some(Kind::Movers(MarketMovers { movers })), + }); + Ok(()) + } + + async fn refresh_details(&self, first_run: bool) -> anyhow::Result<()> { + for symbol in self.symbols() { + let details = self.data.load_ticker_data(&symbol).await?; + let updated = { + let mut st = self.state.lock(); + let Some(t) = st.tickers.iter_mut().find(|t| t.symbol == symbol) else { + continue; + }; + t.company_name = details.company_name; + t.previous_close_price = details.previous_close_price; + t.outstanding_shares = details.outstanding_shares; + if first_run { + t.price = details.price; + } + t.clone() + }; + self.broadcast(upsert(updated)); + } + Ok(()) + } + + async fn refresh_aggs(&self) -> anyhow::Result<()> { + let day = latest_trading_day(); + for symbol in self.symbols() { + let aggs = self + .data + .get_today_aggs(day, &symbol, AGG_RANGE_MINUTES) + .await?; + let updated = { + let mut st = self.state.lock(); + let Some(t) = st.tickers.iter_mut().find(|t| t.symbol == symbol) else { + continue; + }; + // Diff by content, not slice length (the Go version only compared + // len, so same-count value changes were dropped). + if t.aggs == aggs { + continue; + } + t.aggs = aggs; + t.clone() + }; + self.broadcast(upsert(updated)); + } + Ok(()) + } + + /// Spawn the background loops (price feed, aggregate + detail refresh). + pub fn spawn_background(self: &Arc, cancel: CancellationToken) { + { + let this = self.clone(); + let cancel = cancel.clone(); + tokio::spawn(async move { this.price_feed_loop(cancel).await }); + } + { + let this = self.clone(); + let cancel = cancel.clone(); + tokio::spawn(async move { + this.refresh_loop(cancel, TICKER_AGGS_REFRESH_INTERVAL, false) + .await + }); + } + { + let this = self.clone(); + let cancel = cancel.clone(); + tokio::spawn(async move { + this.refresh_loop(cancel, TICKER_DETAILS_REFRESH_INTERVAL, true) + .await + }); + } + { + let this = self.clone(); + tokio::spawn(async move { this.movers_loop(cancel).await }); + } + } + + /// Periodically refresh the gainers/losers tape. Errors are logged, not fatal. + async fn movers_loop(self: Arc, cancel: CancellationToken) { + let mut interval = tokio::time::interval(MOVERS_REFRESH_INTERVAL); + interval.tick().await; // consume the immediate first tick (loaded at startup) + loop { + tokio::select! { + _ = cancel.cancelled() => break, + _ = interval.tick() => { + if let Err(e) = self.refresh_movers().await { + error!(error = %e, "periodic movers refresh failed"); + } + } + } + } + } + + /// Periodic refresh loop. `details` selects details-vs-aggregates. A failed + /// refresh is logged but never terminates the loop (one bad API call + /// shouldn't take the leader down). + async fn refresh_loop( + self: Arc, + cancel: CancellationToken, + period: Duration, + details: bool, + ) { + let mut interval = tokio::time::interval(period); + interval.tick().await; // consume the immediate first tick (already loaded at startup) + loop { + tokio::select! { + _ = cancel.cancelled() => break, + _ = interval.tick() => { + let result = if details { + self.refresh_details(false).await + } else { + self.refresh_aggs().await + }; + if let Err(e) = result { + error!(error = %e, details, "periodic refresh failed"); + } + } + } + } + } + + /// Consume the live price feed and forward each tick onto the broadcast bus. + /// Reconnects on error and re-subscribes when the ticker set changes. + async fn price_feed_loop(self: Arc, cancel: CancellationToken) { + let (tx, mut rx) = mpsc::channel::(10_000); + + // Forwarder: price updates -> broadcast bus. + { + let this = self.clone(); + let cancel = cancel.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + _ = cancel.cancelled() => break, + maybe = rx.recv() => match maybe { + Some(update) => this.broadcast(Update { kind: Some(Kind::Price(update)) }), + None => break, + }, + } + } + }); + } + + // Producer: (re)connect the websocket for the current symbol set. + while !cancel.is_cancelled() { + let symbols = self.symbols(); + if symbols.is_empty() { + tokio::select! { + _ = cancel.cancelled() => break, + _ = self.resubscribe.notified() => continue, + } + } + + let child = cancel.child_token(); + let mut listener = { + let data = self.data.clone(); + let tx = tx.clone(); + let child = child.clone(); + tokio::spawn( + async move { data.listen_for_price_updates(&symbols, tx, child).await }, + ) + }; + + tokio::select! { + _ = cancel.cancelled() => { + child.cancel(); + let _ = (&mut listener).await; + break; + } + _ = self.resubscribe.notified() => { + // Ticker set changed; drop this connection and reconnect. + child.cancel(); + let _ = (&mut listener).await; + } + res = &mut listener => { + if let Ok(Err(e)) = res { + warn!(error = %e, "price feed error; reconnecting"); + } + if cancel.is_cancelled() { + break; + } + tokio::time::sleep(PRICE_FEED_RETRY).await; + } + } + } + } +} + +/// Serve the gRPC `Leader` service until `cancel` fires. +pub async fn serve_grpc( + leader: Arc, + addr: SocketAddr, + cancel: CancellationToken, +) -> anyhow::Result<()> { + use tickerwall_proto::leader_server::LeaderServer; + + info!(%addr, "gRPC leader listening"); + tonic::transport::Server::builder() + .add_service(LeaderServer::from_arc(leader)) + .serve_with_shutdown(addr, async move { cancel.cancelled().await }) + .await?; + Ok(()) +} + +/// Convenience: wrap a ticker in an "upserted" update. +fn upsert(ticker: Ticker) -> Update { + Update { + kind: Some(Kind::TickerUpserted(ticker)), + } +} + +pub(crate) fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +// Re-export for the app binary to build its config. +pub use tickerwall_proto::PresentationSettings as Settings; diff --git a/crates/leader/src/service.rs b/crates/leader/src/service.rs new file mode 100644 index 0000000..275387c --- /dev/null +++ b/crates/leader/src/service.rs @@ -0,0 +1,216 @@ +//! tonic `Leader` service implementation. Each mutation updates state under the +//! lock, then broadcasts the resulting change to all streaming clients. + +use std::pin::Pin; +use std::sync::Arc; + +use futures::Stream; +use parking_lot::Mutex; +use tokio::sync::broadcast; +use tonic::{Request, Response, Status}; +use tracing::{info, warn}; + +use tickerwall_proto::{ + leader_server, AnnounceRequest, Announcement, Empty, JoinRequest, MarketMovers, + PresentationSettings, Screen, SettingsPatch, Snapshot, TickerSymbol, Update, + UpdateKind as Kind, +}; + +use crate::{now_ms, Leader, LeaderState, AGG_RANGE_MINUTES, ANNOUNCEMENT_LEAD_MS}; + +/// Removes a screen from the cluster (and announces the change) when a client's +/// stream is dropped — i.e. when the GUI disconnects. +struct ScreenGuard { + state: Arc>, + tx: broadcast::Sender, + uuid: String, +} + +impl Drop for ScreenGuard { + fn drop(&mut self) { + let cluster = { + let mut st = self.state.lock(); + st.remove_screen(&self.uuid); + st.cluster() + }; + let _ = self.tx.send(Update { + kind: Some(Kind::Cluster(cluster)), + }); + info!(uuid = %self.uuid, "screen left cluster"); + } +} + +#[tonic::async_trait] +impl leader_server::Leader for Leader { + type JoinClusterStream = Pin> + Send>>; + + async fn join_cluster( + &self, + request: Request, + ) -> Result, Status> { + let screen = request + .into_inner() + .screen + .ok_or_else(|| Status::invalid_argument("join request missing screen"))?; + let uuid = screen.uuid.clone(); + info!(uuid = %uuid, index = screen.index, width = screen.width, height = screen.height, "screen joined cluster"); + + // Subscribe BEFORE broadcasting so this client sees the join update too. + let rx = self.tx.subscribe(); + let cluster = { + let mut st = self.state.lock(); + st.add_screen(screen); + st.cluster() + }; + self.broadcast(Update { + kind: Some(Kind::Cluster(cluster)), + }); + + let guard = ScreenGuard { + state: self.state.clone(), + tx: self.tx.clone(), + uuid, + }; + + // The guard lives inside the stream generator; when the client + // disconnects, tonic drops the stream, dropping the guard and removing + // the screen. + let stream = async_stream::stream! { + let _guard = guard; + let mut rx = rx; + loop { + match rx.recv().await { + Ok(update) => yield Ok(update), + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!(skipped = n, "client lagged; skipping ahead"); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + }; + + Ok(Response::new(Box::pin(stream))) + } + + async fn get_snapshot(&self, _request: Request) -> Result, Status> { + let st = self.state.lock(); + Ok(Response::new(Snapshot { + cluster: Some(st.cluster()), + tickers: st.tickers.clone(), + movers: Some(MarketMovers { + movers: st.movers.clone(), + }), + })) + } + + async fn update_settings( + &self, + request: Request, + ) -> Result, Status> { + let patch = request.into_inner(); + let settings = { + let mut st = self.state.lock(); + st.settings.apply_patch(&patch); + st.settings + }; + self.broadcast(Update { + kind: Some(Kind::Settings(settings)), + }); + Ok(Response::new(settings)) + } + + async fn announce( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let announcement = Announcement { + message: req.message, + r#type: req.r#type, + show_at_timestamp_ms: now_ms() + ANNOUNCEMENT_LEAD_MS, + lifespan_ms: req.lifespan_ms, + animation: req.animation, + }; + self.broadcast(Update { + kind: Some(Kind::Announcement(announcement.clone())), + }); + Ok(Response::new(announcement)) + } + + async fn update_screen(&self, request: Request) -> Result, Status> { + let screen = request.into_inner(); + let cluster = { + let mut st = self.state.lock(); + if !st.update_screen(screen.clone()) { + return Err(Status::not_found("unknown screen uuid")); + } + st.cluster() + }; + self.broadcast(Update { + kind: Some(Kind::Cluster(cluster)), + }); + Ok(Response::new(screen)) + } + + async fn add_ticker(&self, request: Request) -> Result, Status> { + let symbol = request.into_inner().symbol.to_uppercase(); + if symbol.is_empty() { + return Err(Status::invalid_argument("empty ticker symbol")); + } + // Already present? Nothing to do. + if self.state.lock().tickers.iter().any(|t| t.symbol == symbol) { + return Ok(Response::new(Empty {})); + } + + // Fetch data before taking the lock. + let mut ticker = self + .data + .load_ticker_data(&symbol) + .await + .map_err(|e| Status::internal(format!("load ticker {symbol}: {e}")))?; + if let Ok(aggs) = self + .data + .get_today_aggs( + tickerwall_data::latest_trading_day(), + &symbol, + AGG_RANGE_MINUTES, + ) + .await + { + ticker.aggs = aggs; + } + + let stored = { + let mut st = self.state.lock(); + st.upsert_ticker(ticker); + st.tickers.iter().find(|t| t.symbol == symbol).cloned() + }; + // Re-subscribe the price feed to include the new symbol. + self.resubscribe.notify_one(); + + if let Some(stored) = stored { + self.broadcast(Update { + kind: Some(Kind::TickerUpserted(stored)), + }); + } + info!(symbol = %symbol, "ticker added"); + Ok(Response::new(Empty {})) + } + + async fn remove_ticker( + &self, + request: Request, + ) -> Result, Status> { + let symbol = request.into_inner().symbol.to_uppercase(); + let removed = self.state.lock().remove_ticker(&symbol); + if !removed { + return Err(Status::not_found("unknown ticker symbol")); + } + self.resubscribe.notify_one(); + self.broadcast(Update { + kind: Some(Kind::TickerRemoved(symbol.clone())), + }); + info!(symbol = %symbol, "ticker removed"); + Ok(Response::new(Empty {})) + } +} diff --git a/crates/leader/src/state.rs b/crates/leader/src/state.rs new file mode 100644 index 0000000..d1fb127 --- /dev/null +++ b/crates/leader/src/state.rs @@ -0,0 +1,167 @@ +//! The leader's authoritative in-memory state and its configuration. Kept behind +//! a `Mutex`; critical sections are short and never hold the lock across `.await`. + +use tickerwall_proto::{ + sort_and_tag_tickers, Mover, PresentationSettings, Screen, ScreenCluster, Ticker, +}; + +/// Configuration used to build a [`crate::Leader`]. +pub struct Config { + pub api_key: String, + /// Initial ticker symbols to display. + pub tickers: Vec, + /// Starting presentation settings (defaults come from the CLI). + pub settings: PresentationSettings, +} + +/// Mutable cluster state: settings, the ticker list, and the connected screens. +pub struct LeaderState { + pub settings: PresentationSettings, + pub tickers: Vec, + /// Currently-connected screens, always sorted ascending by `index`. + pub screens: Vec, + /// Latest top gainers/losers for the secondary tape. + pub movers: Vec, +} + +impl LeaderState { + /// Build a `ScreenCluster` snapshot from the current settings + screens. + pub fn cluster(&self) -> ScreenCluster { + ScreenCluster { + settings: Some(self.settings), + screens: self.screens.clone(), + } + } + + /// Add (or replace, by UUID) a screen and keep the list sorted by index. + pub fn add_screen(&mut self, screen: Screen) { + match self.screens.iter_mut().find(|s| s.uuid == screen.uuid) { + Some(existing) => *existing = screen, + None => self.screens.push(screen), + } + self.sort_screens(); + } + + /// Remove a screen by UUID. Returns whether one was removed. + pub fn remove_screen(&mut self, uuid: &str) -> bool { + let before = self.screens.len(); + self.screens.retain(|s| s.uuid != uuid); + self.sort_screens(); + self.screens.len() != before + } + + /// Update an existing screen's geometry. Returns false if the UUID is unknown. + pub fn update_screen(&mut self, screen: Screen) -> bool { + match self.screens.iter_mut().find(|s| s.uuid == screen.uuid) { + Some(existing) => { + *existing = screen; + self.sort_screens(); + true + } + None => false, + } + } + + fn sort_screens(&mut self) { + self.screens.sort_by_key(|s| s.index); + } + + /// Insert or replace a ticker (by symbol), then re-sort and re-tag indices. + pub fn upsert_ticker(&mut self, ticker: Ticker) { + match self.tickers.iter_mut().find(|t| t.symbol == ticker.symbol) { + Some(existing) => *existing = ticker, + None => self.tickers.push(ticker), + } + sort_and_tag_tickers(&mut self.tickers); + } + + /// Remove a ticker by symbol. Returns whether one was removed. + pub fn remove_ticker(&mut self, symbol: &str) -> bool { + let before = self.tickers.len(); + self.tickers.retain(|t| t.symbol != symbol); + sort_and_tag_tickers(&mut self.tickers); + self.tickers.len() != before + } + + pub fn symbols(&self) -> Vec { + self.tickers.iter().map(|t| t.symbol.clone()).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ticker(symbol: &str) -> Ticker { + Ticker { + symbol: symbol.to_string(), + ..Default::default() + } + } + + fn state() -> LeaderState { + LeaderState { + settings: PresentationSettings::default(), + tickers: Vec::new(), + screens: Vec::new(), + movers: Vec::new(), + } + } + + #[test] + fn screens_stay_sorted_by_index() { + let mut st = state(); + st.add_screen(Screen { + uuid: "b".into(), + index: 20, + width: 100, + height: 10, + }); + st.add_screen(Screen { + uuid: "a".into(), + index: 10, + width: 100, + height: 10, + }); + st.add_screen(Screen { + uuid: "c".into(), + index: 30, + width: 100, + height: 10, + }); + assert_eq!( + st.screens + .iter() + .map(|s| s.uuid.as_str()) + .collect::>(), + ["a", "b", "c"] + ); + assert!(st.remove_screen("b")); + assert_eq!( + st.screens + .iter() + .map(|s| s.uuid.as_str()) + .collect::>(), + ["a", "c"] + ); + assert!(!st.remove_screen("missing")); + } + + #[test] + fn upsert_dedupes_and_retags() { + let mut st = state(); + st.upsert_ticker(ticker("NVDA")); + st.upsert_ticker(ticker("AAPL")); + st.upsert_ticker(ticker("AAPL")); // dedupe by symbol + assert_eq!(st.tickers.len(), 2); + // sorted + tagged + assert_eq!(st.tickers[0].symbol, "AAPL"); + assert_eq!(st.tickers[0].index, 0); + assert_eq!(st.tickers[1].symbol, "NVDA"); + assert_eq!(st.tickers[1].index, 1); + + assert!(st.remove_ticker("AAPL")); + assert_eq!(st.tickers.len(), 1); + assert_eq!(st.tickers[0].index, 0); + } +} diff --git a/crates/proto/Cargo.toml b/crates/proto/Cargo.toml new file mode 100644 index 0000000..f25c5c5 --- /dev/null +++ b/crates/proto/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "tickerwall-proto" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +tonic.workspace = true +prost.workspace = true + +[build-dependencies] +tonic-build.workspace = true +protoc-bin-vendored.workspace = true diff --git a/crates/proto/build.rs b/crates/proto/build.rs new file mode 100644 index 0000000..44fa5f1 --- /dev/null +++ b/crates/proto/build.rs @@ -0,0 +1,9 @@ +fn main() -> Result<(), Box> { + // Use the vendored protoc so the build needs no system protobuf compiler. + let protoc = protoc_bin_vendored::protoc_bin_path()?; + std::env::set_var("PROTOC", protoc); + + println!("cargo:rerun-if-changed=proto/tickerwall.proto"); + tonic_build::compile_protos("proto/tickerwall.proto")?; + Ok(()) +} diff --git a/crates/proto/proto/tickerwall.proto b/crates/proto/proto/tickerwall.proto new file mode 100644 index 0000000..96ecf8d --- /dev/null +++ b/crates/proto/proto/tickerwall.proto @@ -0,0 +1,191 @@ +syntax = "proto3"; + +package tickerwall.v1; + +// Leader is the control + streaming surface a GUI (and the CLI) talks to. +// +// This is the redesigned protocol for the Rust rewrite. Notable changes vs the +// original models.proto: +// * `Update` is a real `oneof` instead of an int tag + parallel optional fields. +// * Presentation settings are patched with an explicit optional-field message so +// partial updates merge instead of clobbering unset fields to defaults. +// * Dynamic ticker add/remove are first-class RPCs the leader actually emits. +// * Announcement types/animations are enums rather than bare int32s. +service Leader { + // Join the screen cluster. The leader streams state changes back until the + // client disconnects. The first message after joining is always a Cluster + // update reflecting the newly-joined screen. + rpc JoinCluster(JoinRequest) returns (stream Update); + + // One-shot snapshot of current cluster + tickers (used on connect and by + // `describe`). + rpc GetSnapshot(Empty) returns (Snapshot); + + // Merge a partial settings patch into the cluster settings and broadcast the + // resolved settings to all screens. + rpc UpdateSettings(SettingsPatch) returns (PresentationSettings); + + // Broadcast an announcement. The leader owns the display timestamp. + rpc Announce(AnnounceRequest) returns (Announcement); + + // Update a screen's geometry after it has joined (e.g. on resize). + rpc UpdateScreen(Screen) returns (Screen); + + // Add / remove a ticker at runtime. Broadcast to all screens. + rpc AddTicker(TickerSymbol) returns (Empty); + rpc RemoveTicker(TickerSymbol) returns (Empty); +} + +// ---- Core domain messages ------------------------------------------------ + +message Ticker { + string symbol = 1; + string company_name = 2; + int64 outstanding_shares = 3; + double price = 4; + double market_cap = 5; + double price_change_percentage = 6; + double previous_close_price = 7; + int32 index = 8; // tape position, assigned by sorting symbols ascending + repeated Agg aggs = 9; +} + +// One aggregate bar used to draw the mini sparkline. +message Agg { + double price = 1; + int32 volume = 2; + int64 timestamp = 3; +} + +message PriceUpdate { + string symbol = 1; + double price = 2; +} + +// One entry in the secondary "market movers" tape (a top gainer or loser). +// Sign of `todays_change_percentage` determines gainer (>=0) vs loser (<0). +message Mover { + string symbol = 1; + double price = 2; + double todays_change = 3; + double todays_change_percentage = 4; +} + +// The full set of movers shown on the secondary tape (top gainers then losers). +message MarketMovers { + repeated Mover movers = 1; +} + +message RGBA { + int32 red = 1; + int32 green = 2; + int32 blue = 3; + int32 alpha = 4; +} + +message Screen { + string uuid = 1; + int32 width = 2; + int32 height = 3; + int32 index = 4; +} + +message ScreenCluster { + PresentationSettings settings = 1; + repeated Screen screens = 2; +} + +// Fully-resolved presentation settings (the cluster's current state). +message PresentationSettings { + int32 ticker_box_width = 1; + int32 scroll_speed = 2; + RGBA up_color = 3; + RGBA down_color = 4; + RGBA bg_color = 5; + RGBA font_color = 6; + RGBA ticker_box_bg_color = 7; + bool show_logos = 8; + bool show_fps = 9; + int32 animation_duration_ms = 10; + bool per_tick_updates = 11; + bool show_movers = 12; // show the secondary gainers/losers tape + int32 movers_scroll_speed = 13; // independent speed for that tape (inverted; 1 = fastest) +} + +// A partial settings update. Only set fields are applied; unset fields are left +// untouched. Scalars use proto3 `optional` so "unset" is distinct from "zero". +message SettingsPatch { + optional int32 ticker_box_width = 1; + optional int32 scroll_speed = 2; + RGBA up_color = 3; // message fields already track presence + RGBA down_color = 4; + RGBA bg_color = 5; + RGBA font_color = 6; + RGBA ticker_box_bg_color = 7; + optional bool show_logos = 8; + optional bool show_fps = 9; + optional int32 animation_duration_ms = 10; + optional bool per_tick_updates = 11; + optional bool show_movers = 12; + optional int32 movers_scroll_speed = 13; +} + +enum AnnouncementType { + ANNOUNCEMENT_TYPE_INFO = 0; + ANNOUNCEMENT_TYPE_DANGER = 1; + ANNOUNCEMENT_TYPE_SUCCESS = 2; +} + +enum AnnouncementAnimation { + ANNOUNCEMENT_ANIMATION_ELASTIC = 0; + ANNOUNCEMENT_ANIMATION_BOUNCE = 1; + ANNOUNCEMENT_ANIMATION_EASE = 2; + ANNOUNCEMENT_ANIMATION_BACK = 3; +} + +message Announcement { + string message = 1; + AnnouncementType type = 2; + int64 show_at_timestamp_ms = 3; // set by the leader + int64 lifespan_ms = 4; + AnnouncementAnimation animation = 5; +} + +// Announce input: like Announcement but without the leader-owned timestamp. +message AnnounceRequest { + string message = 1; + AnnouncementType type = 2; + int64 lifespan_ms = 3; + AnnouncementAnimation animation = 4; +} + +// ---- Stream + snapshot envelopes ----------------------------------------- + +message JoinRequest { + Screen screen = 1; +} + +message Snapshot { + ScreenCluster cluster = 1; + repeated Ticker tickers = 2; + MarketMovers movers = 3; +} + +message TickerSymbol { + string symbol = 1; +} + +// A single state change streamed leader -> GUI. +message Update { + oneof kind { + ScreenCluster cluster = 1; + Ticker ticker_upserted = 2; // added or updated (client dedupes by symbol) + string ticker_removed = 3; // symbol + PriceUpdate price = 4; + Announcement announcement = 5; + PresentationSettings settings = 6; + MarketMovers movers = 7; + } +} + +message Empty {} diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs new file mode 100644 index 0000000..a7cb989 --- /dev/null +++ b/crates/proto/src/lib.rs @@ -0,0 +1,175 @@ +//! Generated protobuf/gRPC types for the ticker wall, plus small domain helpers +//! that live close to the wire types (cluster geometry, settings merge, ticker +//! ordering). These mirror the hand-written helpers from the Go `models` package. + +/// Generated code for the `tickerwall.v1` package. +pub mod pb { + tonic::include_proto!("tickerwall.v1"); +} + +// Re-export the common types at the crate root for ergonomics. +pub use pb::{ + leader_client, leader_server, update::Kind as UpdateKind, Agg, AnnounceRequest, Announcement, + AnnouncementAnimation, AnnouncementType, Empty, JoinRequest, MarketMovers, Mover, + PresentationSettings, PriceUpdate, Rgba, Screen, ScreenCluster, SettingsPatch, Snapshot, + Ticker, TickerSymbol, Update, +}; + +impl ScreenCluster { + /// Sum of the widths of every screen ordered before `uuid`. This is where a + /// given screen's left edge sits within the full tape. Screens are expected + /// to be sorted by `index` (the leader guarantees this). + pub fn screen_global_offset(&self, uuid: &str) -> f32 { + let mut offset = 0.0; + for screen in &self.screens { + if screen.uuid == uuid { + break; + } + offset += screen.width as f32; + } + offset + } + + /// Total pixel width of the whole cluster (all screens combined). + pub fn global_viewport_size(&self) -> i32 { + self.screens.iter().map(|s| s.width).sum() + } + + pub fn number_of_screens(&self) -> usize { + self.screens.len() + } +} + +impl PresentationSettings { + /// Merge a partial patch into these settings. Only fields present on the + /// patch are applied; unset fields are left untouched. This is the single + /// settings-merge path (the Go original had two divergent ones). + pub fn apply_patch(&mut self, patch: &SettingsPatch) { + if let Some(v) = patch.ticker_box_width { + self.ticker_box_width = v; + } + if let Some(v) = patch.scroll_speed { + self.scroll_speed = v; + } + if patch.up_color.is_some() { + self.up_color = patch.up_color; + } + if patch.down_color.is_some() { + self.down_color = patch.down_color; + } + if patch.bg_color.is_some() { + self.bg_color = patch.bg_color; + } + if patch.font_color.is_some() { + self.font_color = patch.font_color; + } + if patch.ticker_box_bg_color.is_some() { + self.ticker_box_bg_color = patch.ticker_box_bg_color; + } + if let Some(v) = patch.show_logos { + self.show_logos = v; + } + if let Some(v) = patch.show_fps { + self.show_fps = v; + } + if let Some(v) = patch.animation_duration_ms { + self.animation_duration_ms = v; + } + if let Some(v) = patch.per_tick_updates { + self.per_tick_updates = v; + } + if let Some(v) = patch.show_movers { + self.show_movers = v; + } + if let Some(v) = patch.movers_scroll_speed { + self.movers_scroll_speed = v; + } + } +} + +/// Sort tickers by symbol ascending and (re)assign each ticker's `index` to its +/// position. Every screen runs this identically, which is what keeps the tape +/// ordering consistent across the cluster. +pub fn sort_and_tag_tickers(tickers: &mut [Ticker]) { + tickers.sort_by(|a, b| a.symbol.cmp(&b.symbol)); + for (i, ticker) in tickers.iter_mut().enumerate() { + ticker.index = i as i32; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn screen(uuid: &str, index: i32, width: i32) -> Screen { + Screen { + uuid: uuid.to_string(), + width, + height: 300, + index, + } + } + + #[test] + fn screen_global_offset_sums_preceding_widths() { + let cluster = ScreenCluster { + settings: None, + screens: vec![ + screen("a", 10, 1920), + screen("b", 20, 1080), + screen("c", 30, 800), + ], + }; + assert_eq!(cluster.screen_global_offset("a"), 0.0); + assert_eq!(cluster.screen_global_offset("b"), 1920.0); + assert_eq!(cluster.screen_global_offset("c"), 3000.0); + assert_eq!(cluster.global_viewport_size(), 3800); + } + + #[test] + fn apply_patch_only_touches_set_fields() { + let mut settings = PresentationSettings { + ticker_box_width: 1100, + scroll_speed: 8, + animation_duration_ms: 500, + per_tick_updates: true, + ..Default::default() + }; + let patch = SettingsPatch { + scroll_speed: Some(5), + ..Default::default() + }; + settings.apply_patch(&patch); + // Changed: + assert_eq!(settings.scroll_speed, 5); + // Untouched (the Go CLI bug would have reset these to defaults): + assert_eq!(settings.ticker_box_width, 1100); + assert_eq!(settings.animation_duration_ms, 500); + assert!(settings.per_tick_updates); + } + + #[test] + fn sort_and_tag_orders_by_symbol() { + let mut tickers = vec![ + Ticker { + symbol: "NVDA".into(), + ..Default::default() + }, + Ticker { + symbol: "AAPL".into(), + ..Default::default() + }, + Ticker { + symbol: "MSFT".into(), + ..Default::default() + }, + ]; + sort_and_tag_tickers(&mut tickers); + assert_eq!(tickers[0].symbol, "AAPL"); + assert_eq!(tickers[0].index, 0); + assert_eq!(tickers[1].symbol, "MSFT"); + assert_eq!(tickers[1].index, 1); + assert_eq!(tickers[2].symbol, "NVDA"); + assert_eq!(tickers[2].index, 2); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..8d6c2cf --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,109 @@ +# Ticker Wall — Architecture + +The ticker wall is a Rust Cargo workspace that builds a single `tickerwall` binary. A +`server` (leader) pulls market data from Massive.com and streams it over gRPC to any number +of `gui` screens, which together render one continuous scrolling tape. (For the original Go +implementation this replaced, see `docs/legacy/ARCHITECTURE.md`.) + +## Workspace layout + +| Crate | Role | +|---|---| +| `crates/proto` | Protobuf schema + `tonic`/`prost` generated code, and domain helpers (cluster geometry, settings merge, ticker ordering). | +| `crates/data` | Massive.com (Polygon) REST + WebSocket market-data client (ticker details, aggregates, live prices, top movers). | +| `crates/leader` | Authoritative state, data-refresh loops, broadcast bus, tonic `Leader` service. | +| `crates/client` | GUI-side cluster client: join-stream, state sync, auto-reconnect. | +| `crates/gui` | `winit` + `glutin` + `femtovg` render loop: full-bleed graph tape, bottom gainers/losers tape, notifications, FPS meter, system panel. | +| `crates/app` | The `tickerwall` binary: `server` / `gui` / `update` / `announce` / `describe`. | + +## Build + +``` +cargo build --release +``` + +Linux build prerequisites (X11 + OpenGL dev headers for the GUI): + +``` +# Debian/Ubuntu +sudo apt-get install -y libgl1-mesa-dev xorg-dev +``` + +macOS needs no extra packages. `protoc` is **not** required — it's vendored via +`protoc-bin-vendored` and used automatically by `crates/proto/build.rs`. + +## Run (development, e.g. on macOS) + +``` +# 1. Leader (pulls data, serves gRPC on :6886) +tickerwall server -a # or set TW_API_KEY + +# 2. One or more GUI screens (each is one window) +tickerwall gui --screen-index 10 +tickerwall gui --screen-index 20 --screen-width 1920 + +# 3. Live control +tickerwall update --scroll-speed 5 # partial: only this field changes +tickerwall update --bg-color 255,255,255,255 +tickerwall announce "Big Success!" --type success --animation ease +tickerwall describe +``` + +Config precedence is **flags > environment > built-in defaults** (via clap's native `env` +support). Environment variables are the flag name uppercased with a `TW_` prefix (e.g. +`TW_API_KEY`, `TW_SCROLL_SPEED`, `TW_TICKER_BOX_WIDTH`). A config-**file** layer (the Go +app's `tickerwall.{yml,json,toml}`) is not implemented — tracked as a possible follow-up. + +## Linux kiosk deployment (bare X, no window manager) + +The deployed screens boot straight into the app with **no window manager**. As with the Go +build (GLFW required X11/Wayland), we run a bare X server and launch the GUI fullscreen — no +desktop environment or WM. `winit` + `glutin` render fine under a bare X server. + +Minimal setup: log a kiosk user in on a TTY and start X with only the app as its client. + +`~/.xinitrc` (the *only* X client — when it exits, X exits): + +```sh +#!/bin/sh +# No window manager. Just the ticker wall, fullscreen. +exec tickerwall gui --leader http://:6886 --screen-index 10 \ + --screen-width 1920 --screen-height 1080 +``` + +Auto-start X on login (e.g. append to `~/.bash_profile`), restarting if it ever exits: + +```sh +if [ -z "$DISPLAY" ] && [ "$(tty)" = "/dev/tty1" ]; then + while true; do startx; sleep 1; done +fi +``` + +Notes: +- The window is created at the requested size; on a dedicated display with no WM it fills the + screen. (A future option can force borderless-fullscreen via winit's fullscreen mode.) +- `femtovg`'s OpenGL ES backend and surfaceless support keep a future **true DRM/KMS** path + (no X server at all) open without changing the drawing code — `winit` has no KMS backend + today, so that path would bypass winit. Not built now. +- Run the leader as a normal systemd service on one host; the screens only need the GUI. + +## Tests + +``` +cargo test # unit tests across all crates (offline) + +# Opt-in live tests against the real API (requires a key): +TW_API_KEY=xxx cargo test -p tickerwall-data --test live_smoke -- --ignored --nocapture +``` + +## Debt fixed vs. the Go original (see `docs/legacy/ARCHITECTURE.md` §12) + +- Partial settings updates merge instead of clobbering unset fields (proven: `update + --scroll-speed` leaves other settings intact). +- `Update` is a real `oneof`; dynamic `add_ticker`/`remove_ticker` are wired end-to-end and + actually emitted (with a live price-feed re-subscribe). +- Aggregates diff by content, not slice length. +- Single announcement-timestamp path; named refresh-interval constants. +- Layout math is unit-tested and the "screen wider than the tape" gap bug is fixed (tickers + wrap and repeat to fill instead of leaving holes). +- No FPS-graph memory-leak workaround; dead logo code omitted. diff --git a/docs/legacy/ARCHITECTURE.md b/docs/legacy/ARCHITECTURE.md new file mode 100644 index 0000000..1fdb2ea --- /dev/null +++ b/docs/legacy/ARCHITECTURE.md @@ -0,0 +1,532 @@ +# Ticker Wall — Current Implementation Reference + +> **Purpose of this document.** This is a snapshot of how the project works *today*, written +> as the foundation for an upcoming refactor. It describes the current design faithfully, +> including its quirks, inconsistencies, and dead code, so that the refactor has a clear +> "before" picture to reason against. It is descriptive, not prescriptive — where behavior is +> surprising or looks like tech debt, it is flagged in a **⚠️ Note** callout rather than "fixed" +> on paper. + +Module: `github.com/massive-com/go-app-ticker-wall/v2` · Go 1.23 + +--- + +## 1. What the application is + +The Ticker Wall is a horizontally scalable, real-time scrolling stock ticker tape. A single +**Leader** process pulls market data from the Massive.com (formerly Polygon) API and pushes it +to any number of **GUI** processes ("screens"). Each GUI renders a slice of one long, shared, +scrolling tape. Multiple GUIs placed side-by-side (on one machine or many) form a single +continuous tape spanning all their displays. + +Everything is driven from one CLI binary (`tickerwall`) with subcommands. Screens can join and +leave at runtime and the tape re-layouts itself live. + +``` + Massive.com API + (REST + WebSocket feeds) + │ + ▼ + ┌─────────────────────────────────────────┐ + │ LEADER │ + │ (one process — the "server" subcommand) │ + │ │ + │ • massive_client → data ingestion │ + │ • leader.Leader → authoritative state │ + │ • gRPC server → streams to GUIs │ + │ • HTTP server → admin/control API │ + └───────────────┬───────────────┬───────────┘ + gRPC stream │ │ gRPC stream + ▼ ▼ + ┌────────────┐ ┌────────────┐ + │ GUI #1 │ │ GUI #2 │ … N GUIs + │ (screen) │ │ (screen) │ + │ client + │ │ client + │ + │ OpenGL win │ │ OpenGL win │ + └────────────┘ └────────────┘ + + CLI control commands (update / announce / describe) are short-lived gRPC + clients that connect to the Leader, issue one RPC, and exit. +``` + +There are exactly **two long-running roles**: one Leader and N GUIs. The Leader can run on the +same machine as a GUI. There is no minimum GUI count and no leader election — the Leader is +started explicitly (see the README TODO about using Raft for v2.0). + +--- + +## 2. Repository layout + +| Package | Role | +|---|---| +| `cmd/cli` | `main` package. Cobra command tree, flag parsing, config precedence, and the short-lived control clients (`update`, `announce`, `describe`). Also the `server` and `gui` launchers. | +| `server` | Wires up and runs the Leader process: the leader state machine, the gRPC server, and the HTTP admin server, all under one tomb. | +| `leader` | The authoritative cluster state and business logic. Data refresh loops, client fan-out, and the gRPC service implementation live here. | +| `massive_client` | Thin wrapper around `massive-com/client-go` for REST (details/prices/aggregates) and WebSocket (live prices). Explicitly labeled a stop-gap in its own source. | +| `client` | The GUI-side cluster client. Connects to the Leader over gRPC, joins the cluster, and keeps a synchronized local copy of state (tickers, settings, screen list, announcements). | +| `gui` | The rendering layer. GLFW window + OpenGL + NanoVG (`nanovgo`). Owns the render loop, layout math, ticker drawing, mini price graphs, and the system/status panel. | +| `gui/notifications` | Full-screen announcement overlays with eased in/out animations. | +| `models` | Protobuf-generated types (`models.pb.go`) plus hand-written helpers (constants/enums, sorting, color conversion, screen-cluster geometry). | +| `fonts` | Roboto TTFs embedded via `go:embed` and registered into the NanoVG context. | + +--- + +## 3. Data model (`models/models.proto`) + +All cross-process data is defined in protobuf and generated by the `./generate` script +(`protoc … --go_out=plugins=grpc`). Key messages: + +- **`Ticker`** — symbol, company name, outstanding shares, price, market cap, price-change %, + previous close, index (sort position), and `repeated Agg Aggs` (the mini-graph data points). + `Img`/`ImgData` fields exist for logos but are effectively unused (see §9). +- **`Agg`** — one aggregate bar: price, volume, timestamp. Used to draw the sparkline graph. +- **`PriceUpdate`** — `{Ticker, Price}`; the lightweight message sent on every live price tick. +- **`Announcement`** — message text, type (info/danger/success), animation, `ShowAtTimestampMS`, + and `LifespanMS`. +- **`Screen`** — `{UUID, Width, Height, Index}`. One per GUI. `Index` controls left-to-right + ordering in the tape. +- **`ScreenCluster`** — `{PresentationSettings, repeated Screen}`. The full shared layout. +- **`PresentationSettings`** — ticker box width, scroll speed, up/down/bg/font/ticker-box colors, + show-logos, show-fps, animation duration, per-tick-updates. +- **`Update`** — the envelope streamed from Leader → GUIs. Carries an `UpdateType` (int) plus one + of the possible payloads (price update, announcement, screen cluster, ticker, settings). + +### Update types (`models/constants.go`) + +`Update.UpdateType` is one of: + +| Value | Constant | Emitted by Leader? | Client action | +|---|---|---|---| +| 1 | `UpdateTypeCluster` | ✅ (join/leave/screen update, HTTP settings) | Replace whole `Cluster` | +| 2 | `UpdateTypeTickerAdded` | ❌ never | Add/replace ticker | +| 3 | `UpdateTypeTickerRemoved` | ❌ never | Remove ticker | +| 4 | `UpdateTypeTickerUpdate` | ✅ (details & aggs refresh) | Add/replace ticker | +| 5 | `UpdateTypeAnnouncement` | ✅ (gRPC + HTTP announce) | Queue announcement | +| 6 | `UpdateTypePrice` | ✅ (live feed) | Update price/market cap/% | +| 7 | `UpdatePresentationSettings` | ✅ (gRPC settings update) | Replace `Cluster.Settings` | + +> **⚠️ Note — dynamic ticker list is not wired up.** The client *handles* `TickerAdded` (2) and +> `TickerRemoved` (3), but the Leader never emits them. The ticker list is fixed at Leader +> startup from the `--tickers` flag; there is no runtime add/remove path. This is a natural +> refactor target if live ticker-list editing is desired. + +--- + +## 4. The Leader (`leader/` + `server/`) + +### 4.1 Startup and lifecycle + +`server.Run` (`server/server.go`) builds a top-level `tomb` context and launches four goroutines: + +1. `clusterLeader.Run(ctx)` — the leader state machine (below). +2. `startGRPC` — the gRPC server on `--grpc-port` (default **6886**). +3. `runHTTPServer` — the Gin HTTP admin API on `--http-port` (default **6887**). +4. A signal handler that kills the tomb on `SIGINT`/`SIGTERM`. + +`leader.New` (`leader/leader.go`) constructs the `Leader` struct, splits the comma-separated +`--tickers` list into `Ticker` stubs, and creates the `massive_client`. + +`Leader.Run` does an initial **synchronous** data load (`refreshTickerDetails` then +`refreshTickerAggs`) so the first client to connect gets populated data, then starts five +long-running loops under its own tomb: + +| Loop (`leader/loops.go`) | Cadence | Purpose | +|---|---|---| +| `DataClient.ListenForTickerUpdates` | continuous | Consume the WebSocket price feed | +| `broadcastPriceUpdatesLoop` | continuous | Move price updates from the data client onto the broadcast channel | +| `clientUpdateLoop` | continuous | Fan every `Update` out to every connected client | +| `tickerAggsUpdateLoop` | every **60 s** | Refresh mini-graph aggregates | +| `tickerDetailsUpdateLoop` | every **500 s** | Refresh company details/prices (comment says "5min"; 500s is ~8.3min) | + +> **⚠️ Note — comment/value drift.** `tickerDetailsUpdateLoop` uses `500 * time.Second` but the +> comment says `// every 5min`. 500 seconds is 8m20s, not 5 minutes. Minor, but worth aligning. + +### 4.2 State and concurrency + +The `Leader` struct holds the authoritative state and embeds `sync.RWMutex`: + +```go +type Leader struct { + sync.RWMutex + config Config + DataClient *massive.Client + PresentationSettings *models.PresentationSettings + Tickers []*models.Ticker + Clients []*UpdateClient // connected screens + Updates chan *models.Update // buffered (1000), the broadcast bus +} +``` + +- **`Updates`** is the single broadcast bus. Anything that changes cluster state pushes an + `*Update` onto it. `clientUpdateLoop` reads it and copies each update onto every client's own + buffered channel (`UpdateClient.Updates`, size 100). +- Each connected screen is an **`UpdateClient`** (`leader/screen-client.go`): `{Screen, Updates + chan, Stream}`. `Clients` is kept sorted ascending by `Screen.Index` (via + `UpdateClientSlice`), which is what makes the tape ordering deterministic. + +### 4.3 gRPC service (`leader/*.go` implement `models.LeaderServer`) + +The proto defines the `Leader` service; the implementation is spread across the `leader` package: + +- **`JoinCluster(Screen) → stream Update`** (`leader/grpc.go`) — the core streaming RPC. Wraps the + screen in an `UpdateClient`, calls `addScreenToCluster` (append, re-sort, broadcast a + `Cluster` update), then blocks reading the client's `Updates` channel and forwarding each to + the stream until the client disconnects. On disconnect it `removeScreenFromCluster` (remove, + re-sort, close channel, broadcast a `Cluster` update). +- **`GetTickers(Empty) → Tickers`** — snapshot of the current ticker list (used by a client right + after joining, and by `describe`). +- **`GetScreenCluster(Empty) → ScreenCluster`** — current cluster snapshot. +- **`UpdatePresentationSettings(PresentationSettings)`** (`leader/update.go`) — **replaces** the + whole settings object and broadcasts `UpdatePresentationSettings` (type 7). +- **`UpdateScreen(Screen)`** (`leader/screen-update.go`) — finds the matching client by UUID, + swaps its `Screen`, and broadcasts a `Cluster` update. Called when a GUI window is resized. +- **`Announce(Announcement)`** (`leader/announce.go`) — stamps `ShowAtTimestampMS = now + 200ms` + and broadcasts an `Announcement` update. + +> **⚠️ Note — two settings-update paths that behave differently.** +> - gRPC `UpdatePresentationSettings` (used by the `update` CLI) **overwrites** the entire +> settings struct and broadcasts type **7** (`UpdatePresentationSettings`), which the client +> applies to `Cluster.Settings` only. +> - HTTP `POST /v1/presentation` (`server/http.go`) **merges** the partial body into current +> settings with `mergo.MergeWithOverwrite`, then broadcasts a **`Cluster`** update (type 1), +> which the client applies by replacing the whole `Cluster`. +> +> Because the CLI path overwrites, running e.g. `tickerwall update --scroll-speed=5` sends a +> *full* `PresentationSettings` populated with flag **defaults** for every field the user didn't +> set (see §7 — the `update` command starts from an empty struct and every presentation/color +> flag has a default). So a single-field CLI update effectively resets the other fields to their +> defaults, whereas the HTTP endpoint truly patches one field. Reconciling these two paths onto +> one consistent merge-or-replace semantics is a clear refactor item. + +### 4.4 HTTP admin API (`server/http.go`, Gin) + +- `GET /ping` — health check. +- `GET /v1/cluster` — returns `CurrentScreenCluster()`. +- `POST /v1/presentation` — partial settings patch via `mergo` (see note above). +- `POST /v1/announcement` — binds an `Announcement` JSON body, stamps `ShowAtTimestampMS = now + + 100ms`, and broadcasts it. + +> **⚠️ Note — announcement timestamp is set in two places with two offsets.** The HTTP handler +> sets `+100ms`; the gRPC `Announce` sets `+200ms`. The gRPC control path (the `announce` CLI) +> and the HTTP path therefore differ slightly. Harmless today, but duplicated logic. + +--- + +## 5. Data ingestion (`massive_client/`) + +This package wraps `massive-com/client-go/v2` (REST + WebSocket). Its own header comment is candid +about being throwaway code ("This library is awful and is a stop gap…"). It is a prime refactor +candidate. + +### 5.1 REST (`client.go`) + +- `LoadTickerData(ticker)` — assembles a `Ticker` from three REST calls: previous close + (`GetPreviousCloseAgg`), current price (`GetLastTrade`), and company details + (`GetTickerDetails` → name + weighted shares outstanding). Used by `refreshTickerDetails`. +- `GetTickerTodayAggs(day, ticker, rangeSize)` — pulls minute aggregates from 9:00 to 16:30 + ET (starts at 9:00 to include significant pre-market), multiplier = `rangeSize` (called with + 10), and maps them into `models.Agg`. Used by `refreshTickerAggs`. `getCurrentOrPreviousWeekday` + walks back over weekends to pick the trading day. + +> **⚠️ Note — aggregate refresh diffs only by length.** `refreshTickerAggs` +> (`leader/tickers.go`) only pushes a `TickerUpdate` when `len(new) != len(existing)`. If the +> count is unchanged but values changed, the update is skipped. There's also a `TODO` about +> normalizing/gap-filling aggregates for an accurate time window. + +### 5.2 WebSocket (`ws.go`) + +`ListenForTickerUpdates` connects, subscribes, and pushes every message onto the +`PriceUpdates` channel (buffered 10,000). The subscription topic depends on `perTickUpdates`: + +- `perTickUpdates == true` → `StocksTrades` (a message per trade, using `trade.Price`). +- `perTickUpdates == false` → `StocksSecAggs` (per-second aggregates, using `agg.Close`). + +> **⚠️ Note — "1/sec" wording.** The `--per-tick-updates` flag help says setting it false "limits +> it to update 1/sec." That maps to per-second aggregates (`StocksSecAggs`), which is accurate, +> but the naming is easy to misread. + +--- + +## 6. The GUI (`gui/` + `client/`) + +A GUI process is two cooperating parts: a **`client.ClusterClient`** (state sync over gRPC) and +the **`gui.GUI`** (window + rendering). They're connected by the `client.Client` interface, which +the GUI uses as its read-only view of state. + +### 6.1 Client-side state sync (`client/`) + +`gui.Run` (`gui/run.go`) creates the client, sets up the window, then runs: + +- `tickerWallClient.Run(ctx)` in a goroutine, and +- `gui.RenderLoop(ctx)` on the main goroutine (OpenGL requires the main thread). + +`ClusterClient.Run` (`client/client.go`) retries `startGRPCClient` until it connects (blocking +dial, 5s timeout, insecure, 1MB max message), then `joinCluster`: + +1. `LoadTickers` — pulls the full ticker list via `GetTickers` and seeds local state. +2. `client.JoinCluster(Screen)` — opens the server-streaming RPC. +3. A **read loop** calls `updateListener.Recv()` and dispatches each `Update` through + `processUpdate`, a switch over `UpdateType` that mutates local state under the client's + `RWMutex`. On stream error it flips status to *Reconnecting*, reconnects, and restarts + `joinCluster`. + +`Status` (`client/status.go`) tracks the gRPC connection as Connected / Reconnecting / +Disconnected. The GUI reads this to decide whether to overlay the system panel. + +Local state is exposed read-only through **accessors** (`client/accessors.go`): `GetTickers`, +`GetSettings`, `GetCluster`, `GetScreen`, `GetAnnouncements`, `GetStatus` — all `RLock`-guarded. +This accessor set is exactly the `client.Client` interface the GUI depends on, so the rendering +layer is decoupled from the concrete client. + +Ticker bookkeeping (`client/tickers.go`): `tickerAdded` dedupes-and-replaces by symbol, +`tickerPriceUpdate` recomputes price/market-cap/percentage, and `sortAndTagTickers` sorts by +symbol and re-assigns each ticker's `Index` (0..n). **That `Index` is the tape position** every +GUI agrees on, because all GUIs sort the same list the same way. + +### 6.2 Rendering stack + +- **Window/context:** GLFW (`goxjs/glfw`) + OpenGL (`goxjs/gl`). `SwapInterval(1)` caps the frame + rate to the display refresh (vsync). +- **Vector drawing:** NanoVG via `massive-com/nanovgo`, plus `perfgraph` for the FPS graph. +- **Fonts:** `fonts.CreateFonts` registers `sans` (Regular), `sans-light` (Light), and + `sans-bold` (Bold) from embedded Roboto TTFs. + +`GUI.Setup` (`gui/gui.go`) inits GLFW, creates the window sized from the screen config, wires the +close/resize callbacks, creates the NanoVG context, loads fonts, sets viewport + pixel ratio, +creates the FPS graph, sets blend state, starts the announcement listener goroutine, and sets up +the logo manager. + +### 6.3 The render loop + +`RenderLoop` runs every frame until the window closes. Per frame (`renderFrame`): + +1. If gRPC status ≠ Connected, defer drawing the **system panel** (so it lands on top). +2. Read `cluster`; if nil (not synced yet), sleep 100ms and skip the frame. +3. Compute the **global offset** (the scroll position) for this instant. +4. Paint the background rectangle. +5. `renderTickers(globalOffset)`. +6. Update + render notifications. +7. Render (or hide) the FPS graph. + +`endFrame` calls `EndFrame`, swaps buffers, and polls events. + +> **⚠️ Note — the FPS graph is always rendered.** `renderFPSGraph` draws the graph on-screen when +> `ShowFPS` is set and *off*-screen (at `-50,-50`) otherwise. The comment explains removing it +> entirely caused "a massive memory leak," so it's always drawn as a workaround. There's a `TODO` +> to find the real leak. Refactor candidate. + +> **⚠️ Note — `GUI.Run` is a no-op.** `gui/run.go` starts `gui.Run(ctx)` in the tomb, but +> `GUI.Run` just `return nil`. The real work is in `RenderLoop`, which runs on the main goroutine. +> The tomb goroutine exists but does nothing. + +### 6.4 The scroll / layout math + +This is the heart of the multi-screen illusion and the trickiest code to preserve through a +refactor. + +**Global offset** (`generateGlobalOffset`, `gui/gui.go`): a time-derived scroll position, shared +in spirit across all screens because it's a pure function of wall-clock time and settings: + +``` +newGlobalOffset = now_ns / (ScrollSpeed * 1ms) +tapeWidth = numTickers * TickerBoxWidth +newGlobalOffset -= floor(newGlobalOffset / tapeWidth) * tapeWidth // wrap into [0, tapeWidth) +``` + +Higher `ScrollSpeed` → larger divisor → slower movement (the flag help notes it's inverted; 1 is +fastest). + +**Per-screen offset** (`ScreenGlobalOffset`, `models/screen-cluster.go`): sums the widths of all +screens with a lower index than this one. That's how each GUI knows *where in the tape* its +left edge sits. `GlobalViewportSize` sums all screen widths (the full tape viewport). + +**Which tickers to draw** (`DetermineTickersForRender`, `gui/layout.go`): from +`globalOffset + screenGlobalOffset` it computes the first and last ticker indices visible on this +screen's width, handling wrap-around at both ends of the tape. + +**Where to draw each** (`TickerOffset`, `gui/layout.go`): `ticker.Index * boxWidth − globalOffset +− screenGlobalOffset`, with a wrap adjustment when the box has scrolled off the left. + +> **⚠️ Note — known layout math bugs, acknowledged in-code.** +> - `renderTickers` skips `nil` tickers with a comment that this happens "when there are more +> screen pixels than we can cover with the current amount of tickers" and points to a math issue +> in `DetermineTickersForRender` (`TODO: Fix layout calculation issue`). +> - `TickerOffset` has a `TODO` about supporting **dynamic ticker box widths** (wide symbols/prices +> currently share a fixed box width, which looks uneven). +> These are load-bearing during a refactor: the current constants and fixed-width assumption are +> baked into both the "which to draw" and "where to draw" calculations. + +### 6.5 Drawing a ticker (`gui/tickers.go`) + +Each ticker box (constants at the top of the file: box height 240, margin 30, padding 50, radius +8; font sizes 96/58; company name truncated at 14 chars) draws: + +- A rounded background rect (`TickerBoxBGColor`). +- Symbol (top-left, bold 96) and price (top-right). +- Company name (bottom-left, light 58, truncated with "…") and the price change `+/- (%)` + (bottom-right), colored with `UpColor`/`DownColor` by sign. +- A **mini price graph** (`drawGraph`) built from `ticker.Aggs`: it finds min/max, normalizes to + a midpoint, clamps movement to ±4% of viewport (`graphViewportPercentage`) with a "squish" step + when out of range, converts to pixel Y, strokes the line, and draws a filled dot at the last + point. Needs ≥2 aggregates or it bails. + +### 6.6 Window events (`gui/window-events.go`) + +- Close: logs only. +- Resize: updates local `windowWidth/Height` and calls `client.UpdateScreen(w, h)`, which mutates + the local `Screen` and fires the `UpdateScreen` gRPC to the Leader (which re-broadcasts the + cluster). There's a `TODO` to debounce this. + +> **⚠️ Note — resize RPC ignores errors and uses a fresh context.** +> `client.broadcastScreenUpdate` (`client/screen.go`) calls `UpdateScreen` with +> `context.Background()` and discards both the result and any error. + +### 6.7 System panel (`gui/system.go`) + +When the gRPC connection isn't healthy, a centered red panel is drawn on top of everything with +"Reconnecting to Leader.." or "Disconnected from Leader..". + +--- + +## 7. Announcements & notifications (`gui/notifications/`) + +Announcements are full-screen overlays that slide in, hold, and slide out. + +- `GUI.listenForAnnouncements` (goroutine started in `Setup`) drains + `client.GetAnnouncements()` and calls `Manager.AddNotification`. +- **`Manager`** (`notification-manager.go`) holds the active notifications plus cached + `settings`/`screen`/`cluster` (refreshed each frame via `UpdateAttributes`). Its `RenderLoop` + renders any notification whose `ShouldRender()` is true and garbage-collects completed ones + in-place (compacting the slice without reallocating). +- **`Notification`** (`notification.go`) computes its own visibility window from + `ShowAtTimestampMS`, `LifespanMS`, and `AnimationDurationMS`. During the enter/exit windows it + interpolates the background rectangle and text position using an easing function from + `fogleman/ease`, chosen by the announcement's `Animation` (elastic / bounce / ease / back). + Background color is picked by announcement type (danger = red, success = green, info = blue). + It positions itself using `ScreenGlobalOffset` / `GlobalViewportSize` so the banner spans the + **entire cluster** and text lands in the true center across all screens. + +> **⚠️ Note — cached render attributes.** `Manager.UpdateAttributes` is called every frame from +> `renderFrame`, so the cache is fresh in practice; but the manager's comment ("If any changes to +> these attributes happen after creation, they will not be updated") reflects an earlier design. +> Worth confirming during refactor that the per-frame refresh is intentional and sufficient. + +--- + +## 8. The CLI (`cmd/cli/`) + +One Cobra root command `tickerwall` with persistent flags `--api-key/-a`, `--leader/-l` +(default `localhost:6886`), `--debug/-d`. Subcommands: + +| Command | What it does | +|---|---| +| `server` | Starts the Leader process (data client + gRPC + HTTP). Flags: `--tickers/-t` (default `AAPL,AMD,NVDA,SBUX,META,HOOD`), `--grpc-port/-g` 6886, `--http-port/-p` 6887, plus the presentation and color flag sets. Requires an API key. | +| `gui` | Starts a GUI window. Flags: `--screen-height/-y` 300, `--screen-width/-x` 1600, `--screen-index/-i` 1. Connects to `--leader`. | +| `update` | Short-lived gRPC client → `UpdatePresentationSettings`. Takes the presentation + color flag sets. | +| `announce [msg]` | Short-lived gRPC client → `Announce`. Flags: `--type/-t` (info/danger/success), `--animation/-n` (elastic/ease/back/bounce), `--lifespan/-i` ms (2000). | +| `describe` | Short-lived gRPC client → `GetScreenCluster` + `GetTickers`, prints a human-readable summary (there's a `TODO` to make it a table). | + +**Shared flag sets** (`flags.go`): `presentationFlags` (scroll speed default 8, ticker box width +1100, animation duration 500ms, per-tick-updates true) and `colorFlags` (up/down/font/ticker-bg/bg +as `r,g,b,a` strings). Colors are parsed by `color-utils.go` (`mapColorArrayToMap`, which +`os.Exit(1)`s if a color string doesn't have exactly 4 comma-separated parts). + +**Control clients** (`client.go`): `ServerClient` is the short-lived gRPC connection used by +`update`/`announce`/`describe` (blocking dial, 5s timeout, insecure, 1MB max message — same +options as the GUI client). + +### Configuration precedence (`cli.go`) + +Config resolves as **CLI flags > environment variables > config file** (README). Implemented with +Viper + a custom `bindFlags`: + +- Config file named `tickerwall` (`.yml`/`.json`/`.toml`) searched in `.` and the home dir. +- Env vars are prefixed `TW_`, with dashes in flag names mapped to underscores (`--debug` → + `TW_DEBUG`, `--grpc-port` → `TW_GRPC_PORT`). +- For each flag not explicitly set on the command line, if Viper has a value it's applied. + +> **⚠️ Note — `update` sends defaults for unset fields.** Because `newUpdateCmd` starts from an +> empty `PresentationSettings{}` and every presentation/color flag has a *default value*, +> `parseColorMap` + the flag defaults populate a fully-formed settings object even when the user +> changes only one field. Combined with the gRPC **replace** semantics (§4.3), a partial `update` +> resets unspecified settings to their defaults. See §4.3 for the full picture. + +--- + +## 9. Logos — present but effectively disabled (`gui/logos.go`) + +There's a full `LogoManager`: it downloads a ticker's logo from +`https://s3.massive.com/logos//logo.png`, buffers the bytes, and — because NanoVG image +creation must happen on the render thread — defers `CreateImageFromMemory` to a `RenderThread()` +call flagged by `NeedsRenderAccess`. + +However: +- `renderTickerLogo` is marked `// nolint:unused` ("Keeping this here incase we want to add logos + back") and is **not called** from the ticker render path. +- `LogoManager.RenderThread()` is **not called** from the render loop. +- The download URL is noted as **deprecated** ("this will not work for newer ticker symbols"). +- The proto `Ticker.Img`/`ImgData` fields are unused. + +So logos are dead/latent code today. The `--show-logos` presentation setting exists but has no +effect. + +--- + +## 10. Concurrency model summary + +- **Leader:** one broadcast channel (`Updates`, buffered 1000) fanned out by `clientUpdateLoop` + to per-client channels (buffered 100). State guarded by a single `sync.RWMutex`. Long-running + work is grouped under `tomb.v2` so a failure/cancel tears the group down cleanly. Data-refresh + loop errors are logged but intentionally **not** fatal (a single failed API call shouldn't kill + the Leader). +- **Client (GUI side):** one goroutine runs the gRPC read loop and mutates state under its own + `RWMutex`; the render loop reads state via `RLock` accessors. A separate goroutine drains the + announcements channel. Reconnect is automatic and transparent. +- **GUI rendering:** all OpenGL/NanoVG calls happen on the main goroutine (the render loop). + Anything that needs the render context from another goroutine (logos) must hand off via a flag — + the pattern exists even though logos are disabled. + +--- + +## 11. Build, generate, run + +- **Generate protobuf:** `./generate` (needs `protoc` + the Go gRPC plugin). Regenerates + `models/models.pb.go` from `models/models.proto`. +- **Platform prereqs (from README):** Linux needs `libgl1-mesa-dev` + `xorg-dev` (X11); macOS + needs nothing extra; Windows untested. +- **Run a cluster:** + ``` + ./tickerwall server -a # start the leader + ./tickerwall gui # first screen + ./tickerwall gui --index=2 # additional screens + ./tickerwall update --scroll-speed=5 # live settings tweak + ./tickerwall announce "Hello" --type=success # live banner + ./tickerwall describe # inspect the cluster + ``` + +--- + +## 12. Consolidated list of refactor flags + +Collected here for convenience — each is detailed inline above. + +1. **Two divergent settings-update paths** — gRPC replaces + broadcasts type 7; HTTP merges + + broadcasts type 1. Client applies them differently. (§4.3, §4.4) +2. **`update` CLI resets unspecified settings** because it sends flag defaults through the + replace-semantics gRPC path. (§7, §4.3) +3. **Dynamic ticker add/remove is unimplemented** — client handles update types 2/3 that the + Leader never emits; ticker list is fixed at startup. (§3) +4. **`massive_client` is explicitly a stop-gap** wrapper reaching across modules. (§5) +5. **Aggregate refresh diffs only by length**, missing same-count value changes; aggregates are + not time-normalized/gap-filled. (§5.1) +6. **FPS graph always rendered** to work around an unresolved memory leak. (§6.3) +7. **Layout math has acknowledged bugs** — nil tickers when pixels exceed ticker coverage; fixed + ticker box width. (§6.4) +8. **Resize RPC ignores errors, no debounce.** (§6.6) +9. **Logo subsystem is dead/latent code**, deprecated URL, `--show-logos` is a no-op. (§9) +10. **Duplicated announcement-timestamp logic** with different offsets (100ms vs 200ms). (§4.4) +11. **`GUI.Run` is a no-op goroutine.** (§6.3) +12. **Comment/value drift** (e.g. details loop "5min" vs 500s). (§4.1) +13. **No leader election** — single explicit Leader; README already flags a Raft-based v2.0. (§1) + + diff --git a/fonts/Roboto-Regular.ttf b/fonts/Roboto-Regular.ttf deleted file mode 100755 index 3e6e2e7..0000000 Binary files a/fonts/Roboto-Regular.ttf and /dev/null differ diff --git a/fonts/fonts.go b/fonts/fonts.go deleted file mode 100644 index 7b70172..0000000 --- a/fonts/fonts.go +++ /dev/null @@ -1,30 +0,0 @@ -// Package fonts is This is a simple wrapper around the fonts in this directly. This reduces the complexity -// of the root application from having to know which directory it needs to be in, as well -// as copying around font files when building/running. -package fonts - -import ( - // We are using embed to prevent loading files from disk, which simplifies everything. - _ "embed" - - "github.com/massive-com/nanovgo" -) - -// nolint:gochecknoglobals // not sure how else to go about this. -//go:embed Roboto-Regular.ttf -var fontsRobotoRegular []byte - -// nolint:gochecknoglobals // not sure how else to go about this. -//go:embed Roboto-Light.ttf -var fontsRobotoLight []byte - -// nolint:gochecknoglobals // not sure how else to go about this. -//go:embed Roboto-Bold.ttf -var fontsRobotoBold []byte - -// CreateFonts attaches the fonts to the nanovgo context. -func CreateFonts(ctx *nanovgo.Context) { - ctx.CreateFontFromMemory("sans", fontsRobotoRegular, 1) - ctx.CreateFontFromMemory("sans-light", fontsRobotoLight, 1) - ctx.CreateFontFromMemory("sans-bold", fontsRobotoBold, 1) -} diff --git a/generate b/generate deleted file mode 100755 index 442d952..0000000 --- a/generate +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash - -# Generate models protobuf: -protoc -I ./models/ -I ${GOPATH}/src --go_out=plugins=grpc:models/ ./models/models.proto --go_opt=paths=source_relative diff --git a/go.mod b/go.mod deleted file mode 100644 index 62c3ee5..0000000 --- a/go.mod +++ /dev/null @@ -1,64 +0,0 @@ -module github.com/massive-com/go-app-ticker-wall/v2 - -go 1.23 - -require ( - github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776 - github.com/gin-gonic/gin v1.7.1 - github.com/google/uuid v1.2.0 - github.com/gorilla/websocket v1.5.3 - github.com/goxjs/gl v0.0.0-20210104184919-e3fafc6f8f2a - github.com/goxjs/glfw v0.0.0-20191126052801-d2efb5f20838 - github.com/imdario/mergo v0.3.12 - github.com/massive-com/client-go/v2 v2.0.0 - github.com/massive-com/nanovgo v0.0.0-20251106212718-3975813b73c7 - github.com/sirupsen/logrus v1.9.3 - github.com/spf13/cobra v1.2.1 - github.com/spf13/pflag v1.0.5 - github.com/spf13/viper v1.9.0 - golang.org/x/net v0.25.0 - google.golang.org/grpc v1.40.0 - google.golang.org/protobuf v1.27.1 - gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 -) - -require ( - github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/fsnotify/fsnotify v1.5.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.3 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect - github.com/go-gl/gl v0.0.0-20210315015930-ae072cafe09d // indirect - github.com/go-gl/glfw/v3.3/glfw v0.0.0-20210311203641-62640a716d48 // indirect - github.com/go-playground/form/v4 v4.2.1 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.23.0 // indirect - github.com/go-resty/resty/v2 v2.13.1 // indirect - github.com/golang/protobuf v1.5.2 // indirect - github.com/gopherjs/gopherjs v0.0.0-20210406100015-1e088ea4ee04 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect - github.com/inconshreveable/mousetrap v1.0.0 // indirect - github.com/json-iterator/go v1.1.11 // indirect - github.com/leodido/go-urn v1.4.0 // indirect - github.com/magiconair/properties v1.8.5 // indirect - github.com/mattn/go-isatty v0.0.12 // indirect - github.com/mitchellh/mapstructure v1.4.2 // indirect - github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect - github.com/modern-go/reflect2 v1.0.1 // indirect - github.com/pelletier/go-toml v1.9.4 // indirect - github.com/shibukawa/nanovgo v0.0.0-20160822101109-9141d09b3652 // indirect - github.com/spf13/afero v1.6.0 // indirect - github.com/spf13/cast v1.4.1 // indirect - github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/subosito/gotenv v1.2.0 // indirect - github.com/ugorji/go/codec v1.1.7 // indirect - golang.org/x/crypto v0.23.0 // indirect - golang.org/x/exp v0.0.0-20220414153411-bcd21879b8fd // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect - google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71 // indirect - gopkg.in/ini.v1 v1.63.2 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - honnef.co/go/js/console v0.0.0-20150119023344-105276c43558 // indirect - honnef.co/go/js/dom v0.0.0-20200509013220-d4405f7ab4d8 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 79945d1..0000000 --- a/go.sum +++ /dev/null @@ -1,805 +0,0 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= -cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= -cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= -cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= -cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= -cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= -cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= -cloud.google.com/go/firestore v1.6.0/go.mod h1:afJwI0vaXwAG54kI7A//lP/lSPDkQORQuMkv56TxEPU= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= -github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776 h1:VRIbnDWRmAh5yBdz+J6yFMF5vso1It6vn+WmM/5l7MA= -github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776/go.mod h1:9wvnDu3YOfxzWM9Cst40msBF1C2UdQgDv962oTxSuMs= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= -github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.7.1 h1:qC89GU3p8TvKWMAVhEpmpB2CIb1hnqt2UdKZaP93mS8= -github.com/gin-gonic/gin v1.7.1/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY= -github.com/go-gl/gl v0.0.0-20210315015930-ae072cafe09d h1:o81yRlBATU4PRn97lydmsq8hTRNXI4wlR/VvUQhFRVY= -github.com/go-gl/gl v0.0.0-20210315015930-ae072cafe09d/go.mod h1:482civXOzJJCPzJ4ZOX/pwvXBWSnzD4OKMdH4ClKGbk= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20210311203641-62640a716d48 h1:QrUfZrT8n72FUuiABt4tbu8PwDnOPAbnj3Mql1UhdRI= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20210311203641-62640a716d48/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/form/v4 v4.2.1 h1:HjdRDKO0fftVMU5epjPW2SOREcZ6/wLUzEobqUGJuPw= -github.com/go-playground/form/v4 v4.2.1/go.mod h1:q1a2BY+AQUUzhl6xA/6hBetay6dEIhMHjgvJiGo6K7U= -github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= -github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= -github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= -github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= -github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g= -github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= -github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs= -github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gopherjs/gopherjs v0.0.0-20210406100015-1e088ea4ee04 h1:Enykqupm0u6qiUZAc+SiFkMJVqt4o8knNcKJu8NdlJ0= -github.com/gopherjs/gopherjs v0.0.0-20210406100015-1e088ea4ee04/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/goxjs/gl v0.0.0-20210104184919-e3fafc6f8f2a h1:zJSqvd6WeaSHaep4NmGZyVjkTsXKCaWbb5G5P+czKME= -github.com/goxjs/gl v0.0.0-20210104184919-e3fafc6f8f2a/go.mod h1:dy/f2gjY09hwVfIyATps4G2ai7/hLwLkc5TrPqONuXY= -github.com/goxjs/glfw v0.0.0-20191126052801-d2efb5f20838 h1:4bHrpuAtLi2HGQZTDqs0yq1smnSG/bJXm4cqelS7EsE= -github.com/goxjs/glfw v0.0.0-20191126052801-d2efb5f20838/go.mod h1:oS8P8gVOT4ywTcjV6wZlOU4GuVFQ8F5328KY3MJ79CY= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/api v1.10.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= -github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= -github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= -github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= -github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= -github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k= -github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= -github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11 h1:uVUAXhF2To8cbw/3xN3pxj6kk7TYKs98NIrTqPlMWAQ= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= -github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= -github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= -github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls= -github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/massive-com/client-go/v2 v2.0.0 h1:hK6SzCIqJU0MlFyM0yXrBZWBmOhActftLO4NRDyrtm4= -github.com/massive-com/client-go/v2 v2.0.0/go.mod h1:YL4vW5Zs8j8r44j3ErSTV9Hn9yw7VRkFGDUL0AKVIN8= -github.com/massive-com/nanovgo v0.0.0-20251106212718-3975813b73c7 h1:Os90C5cQiBcVDzw4Kqw4yPwkTs9JBXcuoTM8kTfWyQM= -github.com/massive-com/nanovgo v0.0.0-20251106212718-3975813b73c7/go.mod h1:6ItxAQcPDVGj+kKWt6oQSBBXUJKOSU8mdvmtFEoG74A= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= -github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.4.2 h1:6h7AQ0yhTcIsmFmnAwQls75jp2Gzs4iB8W7pjMO+rqo= -github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM= -github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= -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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/sagikazarmark/crypt v0.1.0/go.mod h1:B/mN0msZuINBtQ1zZLEQcegFJJf9vnYIR88KRMEuODE= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/shibukawa/nanovgo v0.0.0-20160822101109-9141d09b3652 h1:7ORiLBcdIPEjwwbbjUpYIvy3Z6syqAhnfY/3m5DSs5M= -github.com/shibukawa/nanovgo v0.0.0-20160822101109-9141d09b3652/go.mod h1:A/HWQKoHKEbx5r+LI6Lns9g6vSROSGw59APgU2Zr+Cc= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY= -github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA= -github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v1.2.1 h1:+KmjbUw1hriSNMF55oPrkZcb27aECyrj8V2ytv7kWDw= -github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk= -github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= -github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= -github.com/spf13/viper v1.9.0 h1:yR6EXjTp0y0cLN8OZg1CRZmOBdI88UcGkhgyJhu6nZk= -github.com/spf13/viper v1.9.0/go.mod h1:+i6ajR7OX2XaiBkrcZJFK21htRk7eDeLg7+O6bhUPP4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= -github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= -github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= -github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= -github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= -github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20220414153411-bcd21879b8fd h1:zVFyTKZN/Q7mNRWSs1GOYnHM9NiFSJ54YVRsD0rNWT4= -golang.org/x/exp v0.0.0-20220414153411-bcd21879b8fd/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -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-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/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-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= -google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8= -google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= -google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= -google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= -google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= -google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= -google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= -google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= -google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= -google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= -google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= -google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= -google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71 h1:z+ErRPu0+KS02Td3fOAgdX+lnPDh/VyaABEJPD4JRQs= -google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= -google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= -google.golang.org/grpc v1.40.0 h1:AGJ0Ih4mHjSeibYkFGh1dD9KJ/eOtZ93I6hoHhukQ5Q= -google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -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/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.63.2 h1:tGK/CyBg7SMzb60vP1M03vNZ3VDu3wGQJwn7Sxi9r3c= -gopkg.in/ini.v1 v1.63.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 h1:yiW+nvdHb9LVqSHQBXfZCieqV4fzYhNBql77zY0ykqs= -gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637/go.mod h1:BHsqpu/nsuzkT5BpiH1EMZPLyqSMM8JbIavyFACoFNk= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/js/console v0.0.0-20150119023344-105276c43558 h1:h/U4Bu3p/OB5OvK6+7cXA1oleuDeFzZOTHuwZjMT36A= -honnef.co/go/js/console v0.0.0-20150119023344-105276c43558/go.mod h1:K5NtVTubnQQDVBcHCysv3MwAde+idPmWBeXxpHczwcU= -honnef.co/go/js/dom v0.0.0-20200509013220-d4405f7ab4d8 h1:ufAAo/LVT1mnsG3ivFo3EdGa6E5VeCoBHj0hrmP5Xg0= -honnef.co/go/js/dom v0.0.0-20200509013220-d4405f7ab4d8/go.mod h1:sUMDUKNB2ZcVjt92UnLy3cdGs+wDAcrPdV3JP6sVgA4= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/gui/gui.go b/gui/gui.go deleted file mode 100644 index 2633687..0000000 --- a/gui/gui.go +++ /dev/null @@ -1,247 +0,0 @@ -package gui - -import ( - "context" - "fmt" - "math" - "time" - - "github.com/goxjs/gl" - "github.com/goxjs/glfw" - "github.com/massive-com/go-app-ticker-wall/v2/client" - "github.com/massive-com/go-app-ticker-wall/v2/fonts" - "github.com/massive-com/go-app-ticker-wall/v2/gui/notifications" - "github.com/massive-com/nanovgo" - "github.com/massive-com/nanovgo/perfgraph" - "github.com/sirupsen/logrus" -) - -type GUI struct { - client client.Client - - // nanov - window *glfw.Window - nanoCtx *nanovgo.Context - fpsGraph *perfgraph.PerfGraph - - // - windowHeight int - windowWidth int - pixelRatio float32 - - // logos keeps track of the logos loaded into render context. - logos *LogoManager - - notifications *notifications.Manager -} - -func NewGUI(clientObj client.Client) *GUI { - obj := &GUI{ - client: clientObj, - logos: NewLogosManager(), - // Create notifications manager. - notifications: notifications.NewManager(), - } - - return obj -} - -func (g *GUI) Setup() error { - // Init glfw. - if err := glfw.Init(gl.ContextWatcher); err != nil { - return err - } - - // Get our current screen state. - screen := g.client.GetScreen() - - // Create a new window. - window, err := glfw.CreateWindow( - int(screen.Width), - int(screen.Height), - fmt.Sprintf("Massive Ticker Wall ( INDEX: %d )", screen.Index), - nil, nil, - ) - if err != nil { - return err - } - g.window = window - g.window.MakeContextCurrent() - g.window.SetCloseCallback(g.windowClosedEvent) - g.window.SetSizeCallback(g.windowResizeEvent) - - // Create context - nanoCtx, err := nanovgo.NewContext(0) - if err != nil { - return err - } - g.nanoCtx = nanoCtx - - // This limits the refresh rate to that of the display. - glfw.SwapInterval(1) - - // Load in fonts to our context. - fonts.CreateFonts(g.nanoCtx) - - // Set viewport and pixel ratio. - fbWidth, fbHeight := g.window.GetFramebufferSize() - g.windowWidth, g.windowHeight = g.window.GetSize() - g.pixelRatio = float32(fbWidth) / float32(g.windowWidth) - gl.Viewport(0, 0, fbWidth, fbHeight) - - // Create FPS graph. - g.fpsGraph = perfgraph.NewPerfGraph("Frame Time", "sans") - - // Some additional settings. Don't really know what these mean, using what nanovgo repo code had. - gl.Enable(gl.BLEND) - gl.Disable(gl.CULL_FACE) - gl.Disable(gl.DEPTH_TEST) - gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) - g.nanoCtx.SetFontFace("sans") - g.nanoCtx.SetTextAlign(nanovgo.AlignLeft | nanovgo.AlignTop) - g.nanoCtx.SetTextLineHeight(1.2) - - go g.listenForAnnouncements() - - // Set the logo managers context. - return g.logos.Setup(g.nanoCtx) -} - -// Close shuts down the GUI. -// nolint:unparam // We want to match the io.Closer interface. -func (g *GUI) Close() error { - g.nanoCtx.Delete() - glfw.Terminate() - return nil -} - -// listenForAnnouncements listens on the clients announcements channel and updates our local -// notification manager. -func (g *GUI) listenForAnnouncements() { - ctx := context.Background() - announcements := g.client.GetAnnouncements() - for { - select { - case <-ctx.Done(): - return - case announcement := <-announcements: - g.notifications.AddNotification(announcement) - } - } -} - -func (g *GUI) Run(ctx context.Context) error { - return nil -} - -func (g *GUI) RenderLoop(ctx context.Context) error { - // This is the main rendering loop. Every frame rendered must run everything in this loop. - for !g.window.ShouldClose() { - // Get frame ready. - gl.Clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT) - g.nanoCtx.BeginFrame(g.windowWidth, g.windowHeight, g.pixelRatio) - - if err := g.renderFrame(); err != nil { - logrus.WithError(err).Error("Could not render frame.") - } - - g.endFrame() - } - - return ctx.Err() -} - -func (g *GUI) renderFrame() error { - // Get the client library status. - status := g.client.GetStatus() - - // If we are having issues, display the system dialog panel - if status.GRPCStatus != client.GRPCStatusConnected { - // We use defer so that we render last, making sure we are displayed on top of all other content. - defer func() { - g.SystemPanel() - }() - } - - // Get cluster information. - cluster := g.client.GetCluster() - if cluster == nil { - // This should be displayed on the app using a new system message method. - logrus.Debug("Cluster not ready yet. Waiting on gRPC..") - time.Sleep(100 * time.Millisecond) - return nil - } - - settings := g.client.GetSettings() - screen := g.client.GetScreen() - - g.fpsGraph.UpdateGraph() - - globalOffsetTimestamp := g.generateGlobalOffset() - - // Set BG color - g.paintBG() - - // Tickers. - if err := g.renderTickers(globalOffsetTimestamp); err != nil { - return err - } - - // Notifications. - g.notifications.UpdateAttributes(settings, cluster, screen) - g.notifications.RenderLoop(g.nanoCtx) - - g.renderFPSGraph() - return nil -} - -func (g *GUI) endFrame() { - g.nanoCtx.EndFrame() - gl.Enable(gl.DEPTH_TEST) - g.window.SwapBuffers() - glfw.PollEvents() -} - -// renderFPSGraph always renders the graph, but this decides if it should be displayed -// visibly. Removing the graph caused a massive memory leak. -// TODO: Find/Fix the memory leak so we don't always have to display the graph. -func (g *GUI) renderFPSGraph() { - settings := g.client.GetSettings() - - if settings.ShowFPS { - g.fpsGraph.RenderGraph(g.nanoCtx, 0, 0) - } else { - g.fpsGraph.RenderGraph(g.nanoCtx, -50, -50) - } -} - -// generateGlobalOffset generates the pixel offset taking into account the scroll speed. -func (g *GUI) generateGlobalOffset() float32 { - settings := g.client.GetSettings() - tickers := g.client.GetTickers() - - newGlobalOffset := float64(time.Now().UnixNano()) / float64(int(settings.ScrollSpeed)*int(time.Millisecond)) - - tickerBoxWidth := float32(settings.TickerBoxWidth) - tapeWidth := float32(len(tickers)) * tickerBoxWidth - baseDivisible := math.Floor(newGlobalOffset / float64(tapeWidth)) - newGlobalOffset -= baseDivisible * float64(tapeWidth) - - return float32(newGlobalOffset) -} - -// paintBG sets the background of the window to a solid color. -func (g *GUI) paintBG() { - settings := g.client.GetSettings() - - // Set BG color - g.nanoCtx.BeginPath() - g.nanoCtx.RoundedRect(0, 0, float32(g.windowWidth), float32(g.windowHeight), 0) - g.nanoCtx.SetFillColor(nanovgo.RGBA( - uint8(settings.BGColor.Red), - uint8(settings.BGColor.Green), - uint8(settings.BGColor.Blue), - uint8(settings.BGColor.Alpha), - )) - g.nanoCtx.Fill() -} diff --git a/gui/layout.go b/gui/layout.go deleted file mode 100644 index 69fe4d3..0000000 --- a/gui/layout.go +++ /dev/null @@ -1,83 +0,0 @@ -package gui - -import ( - "math" - - "github.com/massive-com/go-app-ticker-wall/v2/models" -) - -// TickerOffset determines what the offset should be for this ticker, on this screen. -// TODO: We should calculate each tickers offset and allow for dynamic width tickers. When -// there is a 4 letter ticker with a 4 digit price, it's much wider than a 2 letter ticker and 2 digit price -func (g *GUI) TickerOffset(globalOffset float32, ticker *models.Ticker) float32 { - // Get necessary parameters. - settings := g.client.GetSettings() - cluster := g.client.GetCluster() - screen := g.client.GetScreen() - tickers := g.client.GetTickers() - screenGlobalOffset := cluster.ScreenGlobalOffset(screen.UUID) - - tickerBoxWidth := float32(settings.TickerBoxWidth) - tapeWidth := float32(len(tickers)) * tickerBoxWidth - - offset := ((float32(ticker.Index) * tickerBoxWidth) - globalOffset) - screenGlobalOffset - - // Too far left, need to wrap it around. - if offset < 0 { - if offset < -tickerBoxWidth { - offset = tapeWidth - float32(math.Abs(float64(offset))) - } - } - - return offset -} - -// DetermineTickersForRender takes a global offset and returns the ticker indices which are -// within visiable positions ( should be rendered ). -func (g *GUI) DetermineTickersForRender(globalOffset float32) []*models.Ticker { - // Get necessary parameters. - settings := g.client.GetSettings() - cluster := g.client.GetCluster() - screen := g.client.GetScreen() - tickers := g.client.GetTickers() - - // This will be used to build a list of visible tickers at this offset. - var visibleTickers []*models.Ticker - - screenGlobalOffset := cluster.ScreenGlobalOffset(screen.UUID) - - // Global offset does not necessarily ever reset, so we need to get the localized offset. - localizedOffset := globalOffset + screenGlobalOffset - - firstIndex := int(math.Floor(float64(localizedOffset) / float64(settings.TickerBoxWidth))) - lastIndex := int(math.Floor(float64(localizedOffset+float32(g.windowWidth)) / float64(settings.TickerBoxWidth))) - - // eg: -2 - if firstIndex < 0 { - boundedFirst := int(float64(len(tickers)) - math.Abs(float64(firstIndex))) - visibleTickers = append(visibleTickers, tickers[boundedFirst:]...) - // Now we set first index to 0 since we have the overflow items. - firstIndex = 0 - } - - if firstIndex > len(tickers) { - firstIndex = 0 - } - - // If our end index is outside of the bounds. - boundedLastIndex := lastIndex - if lastIndex+1 > len(tickers) { - boundedLastIndex = len(tickers) - 1 - } - - // Add our valid section. - visibleTickers = append(visibleTickers, tickers[firstIndex:boundedLastIndex+1]...) - - // If we have overflow, now add those. - if lastIndex+1 > len(tickers) { - boundedLast := lastIndex - len(tickers) - visibleTickers = append(visibleTickers, tickers[:boundedLast+1]...) - } - - return visibleTickers -} diff --git a/gui/logos.go b/gui/logos.go deleted file mode 100644 index 4da73f0..0000000 --- a/gui/logos.go +++ /dev/null @@ -1,191 +0,0 @@ -package gui - -import ( - "io/ioutil" - "net/http" - "strings" - "sync" - "time" - - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/massive-com/nanovgo" - "github.com/sirupsen/logrus" - "golang.org/x/net/context" -) - -type LogoManager struct { - sync.RWMutex - logoMap map[string]*Logo - nanoCtx *nanovgo.Context - // NeedsRenderAccess is a flag we set when we need access to the main render thread. - // We cannot load images into context unless it's on the main render thread. - NeedsRenderAccess bool -} - -func NewLogosManager() *LogoManager { - return &LogoManager{ - logoMap: make(map[string]*Logo), - } -} - -type Logo struct { - Status logoStatus - NanovImgID int - // tempImgData holds the images data until we can load it into render context. - tempImgData []byte -} - -type logoStatus int - -const ( - // nolint:varcheck,deadcode // This will be used in the future. - logoStatusMissing logoStatus = 0 - logoStatusDownloading logoStatus = 1 - logoStatusError logoStatus = 2 - logoStatusOK logoStatus = 3 - // logoStatusReadyToLoad is used when the ticker has the logo loaded into 'tempImgData' and - // is ready to load it into render context. - logoStatusReadyToLoad logoStatus = 4 -) - -func (l *LogoManager) DownloadLogo(ticker *models.Ticker) error { - logrus.Debug("Downloading logo for: ", ticker.Ticker) - tickerLogo, ok := l.logoMap[ticker.Ticker] - // This should always exist before we get here, but just to make sure... - if !ok { - l.Lock() - defer l.Unlock() - l.logoMap[ticker.Ticker] = &Logo{ - Status: logoStatusDownloading, - } - // Restart the download now it exists... - return l.DownloadLogo(ticker) - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Download URL for logos. ( Deprecated, this will not work for newer ticker symbols ). - // nolint:gosec // We are constructing this URL ourselves, it's OK. - url := "https://s3.massive.com/logos/" + strings.ToLower(ticker.Ticker) + "/logo.png" - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return err - } - - client := http.DefaultClient - response, err := client.Do(req) - if err != nil { - return err - } - defer response.Body.Close() - - // Read the logo bytes into memory. - imgBuff, err := ioutil.ReadAll(response.Body) - if err != nil { - return err - } - - // Create a render context reference to the image. - l.Lock() - defer l.Unlock() - - tickerLogo.tempImgData = imgBuff - tickerLogo.Status = logoStatusReadyToLoad - - // Signal we are ready to load. - l.NeedsRenderAccess = true - - // tickerLogo.NanovImgID = l.nanoCtx.CreateImageFromMemory(0, imgBuff) - - logrus.Debug("Done downloading logo for: ", ticker.Ticker) - return nil -} - -// RenderThread is called in the main rendering thread ( so be fast ). This is required to change -// the context. Without being in the main thread it will cause panics. -func (l *LogoManager) RenderThread() { - l.RLock() - needsThreadAccess := l.NeedsRenderAccess - l.RUnlock() - - // We do not need access - if !needsThreadAccess { - return - } - - l.Lock() - defer l.Unlock() - - for _, tickerLogo := range l.logoMap { - if tickerLogo.Status == logoStatusReadyToLoad { - tickerLogo.NanovImgID = l.nanoCtx.CreateImageFromMemory(0, tickerLogo.tempImgData) - tickerLogo.tempImgData = nil - tickerLogo.Status = logoStatusOK - } - } - - l.NeedsRenderAccess = false -} - -// GetTickerImage attempts to get the tickers logo. If it does not exist it will start the -// download process and return a placeholder image instead. -func (l *LogoManager) GetTickerImage(ticker *models.Ticker) *Logo { - l.RLock() - tickerLogo, ok := l.logoMap[ticker.Ticker] - l.RUnlock() - - // No logo exists for this ticker. - if !ok { - l.Lock() - defer l.Unlock() - - l.logoMap[ticker.Ticker] = &Logo{ - Status: logoStatusDownloading, - } - - // Start the actual download in new go routine. - go func() { - if err := l.DownloadLogo(ticker); err != nil { - logrus.WithError(err).Warn("Download Ticker Logo Failed.") - } - }() - - return nil - } - - // Logo exists and is ready. - if tickerLogo.Status == logoStatusOK { - return tickerLogo - } - - // nolint:staticcheck // Not sure what I want to do with these yet. Keeping for time being. - if tickerLogo.Status == logoStatusDownloading || tickerLogo.Status == logoStatusError { - // Return error image. - } - - return nil -} - -func (l *LogoManager) Setup(nanoCtx *nanovgo.Context) error { - // Load in some default images to context. - logrus.Debug("Setup the logo manager completed.") - l.nanoCtx = nanoCtx - return nil -} - -// renderTickerLogo renders the tickers logo at the given offset & size. -// nolint:unused // Keeping this here incase we want to add logos back. -func (g *GUI) renderTickerLogo(offset, logoSize float32, ticker *models.Ticker) { - tickerImg := g.logos.GetTickerImage(ticker) - if tickerImg == nil { - return - } - - // Paint the logo - imgPaint := nanovgo.ImagePattern(offset, 182.5, logoSize, logoSize, 0.0/180.0*nanovgo.PI, tickerImg.NanovImgID, 1) - g.nanoCtx.BeginPath() - g.nanoCtx.RoundedRect(offset, 182.5, logoSize, logoSize, 5) - g.nanoCtx.SetFillPaint(imgPaint) - g.nanoCtx.Fill() -} diff --git a/gui/notifications/notification-manager.go b/gui/notifications/notification-manager.go deleted file mode 100644 index ab702f1..0000000 --- a/gui/notifications/notification-manager.go +++ /dev/null @@ -1,73 +0,0 @@ -package notifications - -import ( - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/massive-com/nanovgo" - "github.com/sirupsen/logrus" -) - -type Manager struct { - Notifications []*Notification - - // These attributes are collected on initialization. If any changes to these attributes happen - // after creation, they will not be updated. - settings *models.PresentationSettings - screen *models.Screen - cluster *models.ScreenCluster -} - -func NewManager() *Manager { - mgr := &Manager{} - return mgr -} - -// UpdateAttributes is used to update the attributes needed during rendering. -// TODO: We should come up with a better way to do this. -func (m *Manager) UpdateAttributes(settings *models.PresentationSettings, cluster *models.ScreenCluster, screen *models.Screen) { - m.settings = settings - m.cluster = cluster - m.screen = screen -} - -// RenderLoop loops through our current notifications to see if there are any which we should -// call rendering methods on. -func (m *Manager) RenderLoop(ctx *nanovgo.Context) { - validCount := 0 - didGC := false - for _, notification := range m.Notifications { - if notification.HasCompleted { - didGC = true - continue - } - - // This is setting our valid key to this notification, this allows us to do garbage collection of old - // notifications without allocating a whole new slice. - m.Notifications[validCount] = notification - validCount++ - - if notification.ShouldRender() { - notification.Render(ctx) - } - - } - - if didGC { - logrus.Debug("GC'ing Notifications. Removed: ", len(m.Notifications)-validCount) - // Prevent memory leak by erasing values - for i := validCount; i < len(m.Notifications); i++ { - m.Notifications[i] = nil - } - m.Notifications = m.Notifications[:validCount] - } -} - -func (m *Manager) AddNotification(announcement *models.Announcement) { - obj := &Notification{ - mgr: m, - announcement: announcement, - HasCompleted: false, - } - obj.setup() - - m.Notifications = append(m.Notifications, obj) -} diff --git a/gui/notifications/notification.go b/gui/notifications/notification.go deleted file mode 100644 index dadd5e3..0000000 --- a/gui/notifications/notification.go +++ /dev/null @@ -1,144 +0,0 @@ -package notifications - -import ( - "time" - - ease "github.com/fogleman/ease" - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/massive-com/nanovgo" -) - -type Notification struct { - mgr *Manager - announcement *models.Announcement - HasCompleted bool - - // State attributes. - transformationAnimationOut func(float64) float64 - transformationAnimationIn func(float64) float64 -} - -// setup gets the stateful attributes ready for rendering. -func (n *Notification) setup() { - n.determineAnimations() -} - -// ShouldRender checks to see if this notification should be rendered. If it's past the given lifespan -// then we set it to completed, so we can garbage collect it. -func (n *Notification) ShouldRender() bool { - // Current timestamp ( MS ) - t := time.Now().UnixMilli() - - // We are outside of this messages lifespan, disregard. - if t < n.announcement.ShowAtTimestampMS { - return false - } else if t > (n.announcement.ShowAtTimestampMS + n.announcement.LifespanMS + int64(n.mgr.settings.AnimationDurationMS)) { - // This is past our timestamps, GC this announcement. - n.HasCompleted = true - return false - } - - return true -} - -// determineAnimations sets the animation effects for the intro/outro of the notification. -func (n *Notification) determineAnimations() { - switch models.AnnouncementAnimation(n.announcement.Animation) { - case models.AnnouncementAnimationBounce: - // bounce looks weird on out, this seems more natural. - n.transformationAnimationOut = ease.InElastic - n.transformationAnimationIn = ease.OutBounce - case models.AnnouncementAnimationEase: - n.transformationAnimationOut = ease.InQuint - n.transformationAnimationIn = ease.OutQuint - case models.AnnouncementAnimationBack: - n.transformationAnimationOut = ease.InBack - n.transformationAnimationIn = ease.OutBack - default: - n.transformationAnimationOut = ease.InElastic - n.transformationAnimationIn = ease.OutElastic - } -} - -// Render actually renders the notification to the GUI context. `ShouldRender` should be run before this -// to ensure the rendering method should be called on this notification. -func (n *Notification) Render(ctx *nanovgo.Context) { - // Current timestamp ( MS ) - t := time.Now().UnixMilli() - - // Get necessary parameters. - settings := n.mgr.settings - screen := n.mgr.screen - cluster := n.mgr.cluster - - // Text Settings. - textTopStart := float64(-300) - textTopEnd := float64(140) - textTop := textTopEnd - - // BG Settings. - bgBottomStart := float64(0) - bgBottomEnd := float64(screen.Height) - bgBottom := bgBottomEnd - bgTop := (bgBottom - float64(screen.Height)) - - // Determine which animation to use for the announcement. - // To see more: https://github.com/fogleman/ease - - if t-n.announcement.ShowAtTimestampMS < int64(settings.AnimationDurationMS) { - // Enter animation is in progress. - diff := t - n.announcement.ShowAtTimestampMS - percentageCompleted := float64(diff) / float64(settings.AnimationDurationMS) - - // bg calcs - inPercCompleted := n.transformationAnimationIn(percentageCompleted) - bgBottom = bgBottomStart - ((bgBottomStart - bgBottomEnd) * inPercCompleted) - bgTop = (bgBottom - float64(screen.Height)) - - // text calcs - textTop = textTopStart - ((textTopStart - textTopEnd) * inPercCompleted) - - } else if t > n.announcement.ShowAtTimestampMS+n.announcement.LifespanMS { - // Exit animation in progress. - diff := t - (n.announcement.ShowAtTimestampMS + n.announcement.LifespanMS) - percentageCompleted := float64(diff) / float64(settings.AnimationDurationMS) - - // bg calcs - outPercCompleted := n.transformationAnimationOut(percentageCompleted) - bgBottom = bgBottomEnd - ((bgBottomEnd - bgBottomStart) * outPercCompleted) - bgTop = (bgBottom - float64(screen.Height)) - - // text calcs - textTop = textTopEnd - ((textTopEnd - textTopStart) * outPercCompleted) - } - - ctx.Save() - defer ctx.Restore() - - ctx.BeginPath() - // Determine where the box should start ( may not be on our screen ). - screenGlobalOffset := cluster.ScreenGlobalOffset(screen.UUID) - left := -float32(screenGlobalOffset) - // Position bg. - ctx.RoundedRect(left, float32(bgTop), float32(cluster.GlobalViewportSize()), float32(bgBottom), 0) - - // Determine background color based on announcement type:]. - if n.announcement.AnnouncementType == int32(models.AnnouncementTypeDanger) { - ctx.SetFillColor(nanovgo.RGBA(255, 122, 122, 222)) - } else if n.announcement.AnnouncementType == int32(models.AnnouncementTypeSuccess) { - ctx.SetFillColor(nanovgo.RGBA(122, 255, 122, 222)) - } else { - ctx.SetFillColor(nanovgo.RGBA(122, 122, 255, 222)) - } - - ctx.Fill() - - ctx.SetFontSize(96.0) - ctx.SetFontFace("sans-bold") - ctx.SetTextAlign(nanovgo.AlignCenter | nanovgo.AlignMiddle) - - // ctx.SetFontBlur(0) - ctx.SetFillColor(nanovgo.RGBA(255, 255, 255, 255)) - middle := (float32(cluster.GlobalViewportSize()) / 2) - float32(screenGlobalOffset) - ctx.Text(middle, float32(textTop), n.announcement.Message) -} diff --git a/gui/run.go b/gui/run.go deleted file mode 100644 index 40297d6..0000000 --- a/gui/run.go +++ /dev/null @@ -1,52 +0,0 @@ -package gui - -import ( - "context" - "fmt" - - "github.com/massive-com/go-app-ticker-wall/v2/client" - - tombv2 "gopkg.in/tomb.v2" -) - -type Config struct { - Debug bool - ClientConfig client.Config -} - -func Run(cfg *Config) error { - // Global top level context. - tomb, ctx := tombv2.WithContext(context.Background()) - - // Ticker wall client. - tickerWallClient, err := client.New(cfg.ClientConfig) - if err != nil { - return fmt.Errorf("unable to create client: %w", err) - } - defer tickerWallClient.Close() - - // Create a new GUI client ( can only be 1 at a time ). - gui := NewGUI(tickerWallClient) - defer gui.Close() - - // Setup our GUI - if err := gui.Setup(); err != nil { - return fmt.Errorf("could not start gui: %w", err) - } - - // tomb will context the context - tomb.Go(func() error { - return tickerWallClient.Run(ctx) - }) - - // tomb will context the context - tomb.Go(func() error { - return gui.Run(ctx) - }) - - err = gui.RenderLoop(ctx) - - tomb.Kill(err) - - return tomb.Wait() -} diff --git a/gui/system.go b/gui/system.go deleted file mode 100644 index fcb4078..0000000 --- a/gui/system.go +++ /dev/null @@ -1,38 +0,0 @@ -package gui - -import ( - "github.com/massive-com/go-app-ticker-wall/v2/client" - "github.com/massive-com/nanovgo" -) - -func (g *GUI) SystemPanel() { - status := g.client.GetStatus() - screen := g.client.GetScreen() - - systemDialogPanelHeight := 200 - systemDialogPadding := float32(20) - - fromTop := (screen.Height / 2) - (int32(systemDialogPanelHeight) / 2) - - // Set BG color. - g.nanoCtx.BeginPath() - g.nanoCtx.RoundedRect(systemDialogPadding, float32(fromTop), float32(g.windowWidth)-(systemDialogPadding*2), float32(systemDialogPanelHeight), 5) - g.nanoCtx.SetFillColor(nanovgo.RGBA(255, 0, 0, 222)) - g.nanoCtx.Fill() - - // Set font settings. - g.nanoCtx.SetFontFace("sans-bold") - g.nanoCtx.SetTextAlign(nanovgo.AlignCenter | nanovgo.AlignMiddle) - g.nanoCtx.SetTextLineHeight(1.2) - g.nanoCtx.SetFontSize(32.0) - g.nanoCtx.SetFillColor(nanovgo.RGBA(255, 255, 255, 255)) - - message := "System Panel" - if status.GRPCStatus == client.GRPCStatusReconnecting { - message = "Reconnecting to Leader.." - } else if status.GRPCStatus == client.GRPCStatusDisconnected { - message = "Disconnected from Leader.." - } - - g.nanoCtx.Text(float32(screen.Width)/2, float32(screen.Height)/2, message) -} diff --git a/gui/tickers.go b/gui/tickers.go deleted file mode 100644 index 9772c6a..0000000 --- a/gui/tickers.go +++ /dev/null @@ -1,196 +0,0 @@ -package gui - -import ( - "fmt" - "math" - - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/massive-com/nanovgo" -) - -func (g *GUI) renderTickers(globalOffset float32) error { - tickers := g.DetermineTickersForRender(globalOffset) - for _, ticker := range tickers { - // This happens when there are more screen pixels than we can cover with the current amount of tickers. - // The solution is to add more tickers, but we should fix the underlying maths issue in `DetermineTickersForRender`. - // TODO: Fix layout calculation issue in `DetermineTickersForRender` - if ticker == nil { - continue - } - g.renderTicker(ticker, globalOffset) - } - - return nil -} - -const ( - graphSize = 180 - // graphViewportPercentage is the amount of movement ( up or down ) we chart at - // native scale before we are required to "squish" to fit into the viewport. - graphViewportPercentage = .04 // 4% viewport movement range up or down ( 8% total ). - - // Ticker box settings. - tickerBoxHeight = 240 - tickerBoxMargin = 30 - tickerBoxPadding = 50 - tickerBoxBorderRadius = 8 - - // Font sizes. - upperRowFontSize = 96 - bottomRowFontSize = 58 - - maxCompanyNameCharacters = 14 -) - -// renderTickerBg sets the background of the ticker box to a solid color. -func (g *GUI) renderTickerBg(leftOffset float32) { - screen := g.client.GetScreen() - settings := g.client.GetSettings() - - topOffset := float32((screen.Height / 2) - (tickerBoxHeight / 2)) - leftOffset += (tickerBoxMargin / 2) - boxWidth := float32(settings.TickerBoxWidth) - tickerBoxMargin - - // Set BG color - g.nanoCtx.BeginPath() - g.nanoCtx.RoundedRect(leftOffset, topOffset, boxWidth, tickerBoxHeight, tickerBoxBorderRadius) - g.nanoCtx.SetFillColor(settings.TickerBoxBGColor.ToNanov()) - g.nanoCtx.Fill() -} - -func (g *GUI) renderTicker(ticker *models.Ticker, globalOffset float32) { - // Get necessary parameters. - settings := g.client.GetSettings() - screen := g.client.GetScreen() - tickerOffset := g.TickerOffset(globalOffset, ticker) - - // Render background rectangle. - g.renderTickerBg(tickerOffset) - - // Calculate offsets. - offsetLeft := (tickerOffset + (tickerBoxMargin / 2)) + tickerBoxPadding - offsetTop := float32((screen.Height / 2) - (tickerBoxHeight / 2)) - offsetRight := ((tickerOffset + float32(settings.TickerBoxWidth)) - tickerBoxMargin) - tickerBoxPadding - - // Calculate the Y offset for the two rows. Using percentages so if we change - // ticker box size, it should scale accordingly. - upperRowTopOffset := offsetTop + (tickerBoxHeight * .33) - lowerRowTopOffset := offsetTop + (tickerBoxHeight * .66) - - // Actual text rendering --- - - // Ticker. - g.nanoCtx.SetFontFace("sans-bold") - g.nanoCtx.SetTextAlign(nanovgo.AlignLeft | nanovgo.AlignMiddle) - g.nanoCtx.SetFontSize(upperRowFontSize) - g.nanoCtx.SetFillColor(settings.FontColor.ToNanov()) - g.nanoCtx.TextBox(offsetLeft, upperRowTopOffset, 900, ticker.Ticker) - - // Price. - textString := fmt.Sprintf("%.2f", ticker.Price) - boundedTextWidth, _ := g.nanoCtx.TextBounds(0, 0, textString) - g.nanoCtx.Text(offsetRight-boundedTextWidth, upperRowTopOffset, textString) - - // Company Name. - g.nanoCtx.SetFontSize(bottomRowFontSize) - g.nanoCtx.SetFontFace("sans-light") - companyName := ticker.CompanyName - if len(companyName) >= maxCompanyNameCharacters { - companyName = companyName[:(maxCompanyNameCharacters-3)] + "..." - } - g.nanoCtx.TextBox(offsetLeft, lowerRowTopOffset, 900, companyName) - - // Percentage Gained / Loss test. - directionalColor := settings.UpColor - if ticker.PriceChangePercentage < 0 { - directionalColor = settings.DownColor - } - priceDifference := ticker.Price - ticker.PreviousClosePrice - g.nanoCtx.SetFillColor(directionalColor.ToNanov()) - textString = fmt.Sprintf("%+.2f (%+.2f%%)", priceDifference, ticker.PriceChangePercentage) - boundedTextWidth, _ = g.nanoCtx.TextBounds(0, 0, textString) - g.nanoCtx.Text(offsetRight-boundedTextWidth, lowerRowTopOffset, textString) - - // Graph. - topOffset := float32((screen.Height / 2) - (graphSize / 2)) - g.renderGraph(ticker, offsetLeft+400, topOffset, graphSize, directionalColor) -} - -func (g *GUI) renderGraph(ticker *models.Ticker, x, y, width float32, color *models.RGBA) { - g.drawGraph(g.nanoCtx, ticker, x, y, width, width, 2, color) -} - -func (g *GUI) drawGraph(ctx *nanovgo.Context, ticker *models.Ticker, x, y, w, h, t float32, color *models.RGBA) { - points := len(ticker.Aggs) - - // if we have no data, don't continue. - if points < 2 { - return - } - - sx := make([]float32, points) - sy := make([]float32, points) - dx := w / float32(points-1) - - // Generate graph points. - var min, max float64 - for i, agg := range ticker.Aggs { - // Check if max. - if agg.Price > max { - max = agg.Price - } - - // Check if min. - if agg.Price < min || min == 0 { - min = agg.Price - } - - // Set X,Y for this point. - sy[i] = float32(agg.Price) - sx[i] = x + float32(i)*dx - } - - // Middle of our range. - midRange := float32((min + max) / 2) - - // Now we must normalize Y axis to fix in our bounds. - var absMax float32 - for i, val := range sy { - sy[i] = (val - midRange) / midRange - absValue := float32(math.Abs(float64(sy[i]))) - if absValue > absMax { - absMax = absValue - } - } - - // If our values are outside of the viewport range percentage, we must squish values - // to be inside our desired viewport range percentage. - if absMax > graphViewportPercentage { - for i, val := range sy { - sy[i] = (val / absMax) * graphViewportPercentage - } - } - - // Change percentage diff to pixel offsets: - middleOfViewport := h / 2 - baseMultiplier := (middleOfViewport / graphViewportPercentage) - for i, val := range sy { - sy[i] = (y + h) - ((baseMultiplier * val) + middleOfViewport) - } - - ctx.BeginPath() - ctx.MoveTo(sx[0], sy[0]) - for i := 1; i < points; i++ { - ctx.LineTo(sx[i], sy[i]) - } - ctx.SetStrokeColor(color.ToNanov()) - ctx.SetStrokeWidth(4.0) - ctx.Stroke() - - ctx.BeginPath() - ctx.Circle(sx[points-1], sy[points-1], 6.0) - ctx.SetFillColor(color.ToNanov()) - ctx.Fill() - - ctx.SetStrokeWidth(1.0) -} diff --git a/gui/utils.go b/gui/utils.go deleted file mode 100644 index a479798..0000000 --- a/gui/utils.go +++ /dev/null @@ -1,10 +0,0 @@ -package gui - -import "math" - -func cosF(a float32) float32 { - return float32(math.Cos(float64(a))) -} -func sinF(a float32) float32 { - return float32(math.Sin(float64(a))) -} diff --git a/gui/window-events.go b/gui/window-events.go deleted file mode 100644 index bc05d17..0000000 --- a/gui/window-events.go +++ /dev/null @@ -1,17 +0,0 @@ -package gui - -import ( - "github.com/goxjs/glfw" - "github.com/sirupsen/logrus" -) - -func (g *GUI) windowClosedEvent(w *glfw.Window) { - logrus.Debug("Window Closed") -} - -func (g *GUI) windowResizeEvent(w *glfw.Window, width int, height int) { - g.windowHeight = height - g.windowWidth = width - // TODO: Add a 100ms (or something) debounce here so we don't update the cluster too often. - g.client.UpdateScreen(width, height) -} diff --git a/justfile b/justfile new file mode 100644 index 0000000..5a67f48 --- /dev/null +++ b/justfile @@ -0,0 +1,48 @@ +# Ticker wall dev shortcuts — run with `just ` (https://github.com/casey/just). +# The leader needs a Massive.com API key in TW_API_KEY. + +# List available recipes. +default: + @just --list + +# Build the workspace (debug). +build: + cargo build + +# Build the optimized release binary (target/release/tickerwall). +release: + cargo build --release + +# Run the test suite. +test: + cargo test --workspace + +# Format check + clippy — matches CI. +lint: + cargo fmt --all --check + cargo clippy --workspace --all-targets -- -D warnings + +# Auto-format the code. +fmt: + cargo fmt --all + +# Run the leader. Extra flags pass through, e.g. `just server --tickers AAPL,NVDA`. +server *ARGS: + cargo run --bin tickerwall -- server {{ARGS}} + +# Run one GUI screen at the given index (default 10). +gui INDEX="10": + cargo run --bin tickerwall -- gui --screen-index {{INDEX}} + +# Build, then run the leader + two GUI screens together (needs TW_API_KEY). +run: + #!/usr/bin/env bash + set -euo pipefail + cargo build + ./target/debug/tickerwall server & + leader=$! + trap 'kill $leader 2>/dev/null || true' EXIT + sleep 4 + ./target/debug/tickerwall gui --screen-index 10 & + ./target/debug/tickerwall gui --screen-index 20 & + wait diff --git a/leader/announce.go b/leader/announce.go deleted file mode 100644 index 9c83d28..0000000 --- a/leader/announce.go +++ /dev/null @@ -1,25 +0,0 @@ -package leader - -import ( - "context" - "time" - - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/sirupsen/logrus" -) - -// Announce will broadcast an announcement update to all clients. -func (t *Leader) Announce(ctx context.Context, announcement *models.Announcement) (*models.Announcement, error) { - logrus.Debug("New Announcement..", announcement) - - // Set the announcement timestamp. - announcement.ShowAtTimestampMS = time.Now().UnixMilli() + 200 - - // Announce to clients. - t.Updates <- &models.Update{ - UpdateType: int32(models.UpdateTypeAnnouncement), - Announcement: announcement, - } - - return announcement, nil -} diff --git a/leader/env.go b/leader/env.go deleted file mode 100644 index b87afed..0000000 --- a/leader/env.go +++ /dev/null @@ -1,12 +0,0 @@ -package leader - -import "github.com/massive-com/go-app-ticker-wall/v2/models" - -// Config handles the default settings, as well as data client auth. -type Config struct { - TickerList string - APIKey string - - // Presentation Default Settings - Presentation *models.PresentationSettings -} diff --git a/leader/grpc.go b/leader/grpc.go deleted file mode 100644 index bab955d..0000000 --- a/leader/grpc.go +++ /dev/null @@ -1,69 +0,0 @@ -package leader - -import ( - "context" - - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/sirupsen/logrus" -) - -func (t *Leader) JoinCluster(screen *models.Screen, stream models.Leader_JoinClusterServer) error { - logrus.WithFields(logrus.Fields{ - "uuid": screen.UUID, - "index": screen.Index, - "width": screen.Width, - "height": screen.Height, - }).Info("Adding screen to cluster.") - - // Create update client - client := &UpdateClient{ - Screen: screen, - Stream: stream, - Updates: make(chan *models.Update, 100), // dont block. - } - - // Add new screen client. - t.addScreenToCluster(client) - - logrus.Debug("Screen added") - - // Remove this screen when we close the request. - defer func() { - if err := t.removeScreenFromCluster(client); err != nil { // When we disconnect, remove from cluster. - logrus.WithError(err).Error("Couldn't remove screen..") - } - }() - - for { - select { - case <-stream.Context().Done(): - // Client has disconnected. - logrus.WithField("client", client.Screen.UUID).Debug("Client has disconnected.") - return nil - case update, ok := <-client.Updates: - if !ok { - return nil - } - - // logrus.WithField("client", client.Screen.UUID).Debug("Sending Client Update") - if err := client.Stream.Send(update); err != nil { - return err - } - } - } -} - -// GetScreenCluster returns our current screen cluster. -func (t *Leader) GetScreenCluster(ctx context.Context, empty *models.Empty) (*models.ScreenCluster, error) { - return t.CurrentScreenCluster(), nil -} - -// GetTickers returns our current state of ticker data. -func (t *Leader) GetTickers(ctx context.Context, empty *models.Empty) (*models.Tickers, error) { - t.RLock() - defer t.RUnlock() - - return &models.Tickers{ - Tickers: t.Tickers, - }, nil -} diff --git a/leader/leader.go b/leader/leader.go deleted file mode 100644 index dcb9cb3..0000000 --- a/leader/leader.go +++ /dev/null @@ -1,117 +0,0 @@ -package leader - -import ( - "context" - "strings" - "sync" - - "github.com/massive-com/go-app-ticker-wall/v2/models" - massive "github.com/massive-com/go-app-ticker-wall/v2/massive_client" - "github.com/sirupsen/logrus" - tombv2 "gopkg.in/tomb.v2" -) - -// Leader manages the state. -type Leader struct { - sync.RWMutex - config Config - - // Client to fetch data. We should use an interface here to allow more flexibility. - DataClient *massive.Client - - // This keeps the presentation settings. - PresentationSettings *models.PresentationSettings - - // Our list of tickers we want to display. - Tickers []*models.Ticker - - // List of clients who are listening for updates. - Clients []*UpdateClient - - // Updates is a buffered channel of generic updates to be broadcast to clients. - // Every update added to this channel will be sent to all active clients. - Updates chan *models.Update -} - -// New creates a new ticker wall leader. -func New(cfg *Config) (*Leader, error) { - obj := &Leader{ - config: *cfg, - PresentationSettings: cfg.Presentation, - Updates: make(chan *models.Update, 1000), - } - - // Split out the tickers from the config. - for _, ticker := range strings.Split(obj.config.TickerList, ",") { - obj.Tickers = append(obj.Tickers, &models.Ticker{ - Ticker: ticker, - }) - } - - // Create new Massive API Client. - var err error - obj.DataClient, err = massive.NewClient(cfg.APIKey, cfg.Presentation.PerTickUpdates) - if err != nil { - return nil, err - } - - return obj, nil -} - -func (t *Leader) Run(ctx context.Context) error { - logrus.Info("Loading ticker data..") - - if err := t.refreshTickerDetails(ctx, true); err != nil { - return err - } - - // Get graph data for all aggs on load. - if err := t.refreshTickerAggs(ctx); err != nil { - return err - } - - logrus.Debug("All ticker data loaded..") - logrus.Info("Ready for Clients.") - - // Create new tomb for this process. - tomb, ctx := tombv2.WithContext(ctx) - - // Start the DataClient socket stream. - tomb.Go(func() error { - logrus.Debug("Starting WebSocket Listener..") - return t.DataClient.ListenForTickerUpdates(ctx, t.getTickerSymbols()) - }) - - // Listen and broadcast price updates. - tomb.Go(func() error { - return t.broadcastPriceUpdatesLoop(ctx) - }) - - // Broadcast updates to clients. - tomb.Go(func() error { - return t.clientUpdateLoop(ctx) - }) - - // Regularly get aggregates for each ticker. - tomb.Go(func() error { - return t.tickerAggsUpdateLoop(ctx) - }) - - // Regularly get details for each ticker. - tomb.Go(func() error { - return t.tickerDetailsUpdateLoop(ctx) - }) - - return tomb.Wait() -} - -// getTickerSymbols returns a slice of only the ticker symbols, not the entire object. -func (t *Leader) getTickerSymbols() []string { - tickers := make([]string, 0, len(t.Tickers)) - - for _, ticker := range t.Tickers { - tickers = append(tickers, ticker.Ticker) - } - - return tickers -} diff --git a/leader/loops.go b/leader/loops.go deleted file mode 100644 index 903c721..0000000 --- a/leader/loops.go +++ /dev/null @@ -1,79 +0,0 @@ -package leader - -import ( - "context" - "time" - - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/sirupsen/logrus" -) - -// broadcastPriceUpdatesLoop listens to updates from the DataClient and sends that to all gRPC clients. -func (t *Leader) broadcastPriceUpdatesLoop(ctx context.Context) error { - // Read from DataClient price updates channel onto our update channel. - for { - select { - case <-ctx.Done(): - return ctx.Err() - case priceUpdate := <-t.DataClient.PriceUpdates: - t.Updates <- &models.Update{ - UpdateType: int32(models.UpdateTypePrice), - PriceUpdate: priceUpdate, - } - } - } -} - -// clientUpdateLoop spins until we have an update, which is then queued up for all existing clients. -func (t *Leader) clientUpdateLoop(ctx context.Context) error { - defer close(t.Updates) - for { - select { - case <-ctx.Done(): - return ctx.Err() - case update := <-t.Updates: - t.RLock() - - // Put this update on the clients queue. - for _, client := range t.Clients { - client.Updates <- update - } - - t.RUnlock() - } - } -} - -// tickerAggsUpdateLoop continually updates each tickers aggregates. -func (t *Leader) tickerAggsUpdateLoop(ctx context.Context) error { - timer1 := time.NewTicker(60 * time.Second) - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer1.C: - if err := t.refreshTickerAggs(ctx); err != nil { - logrus.WithError(err).Error("Unable to update ticker aggs.") - // We probably don't want to completely exit if ever 1 API call fails. - // return err - } - } - } -} - -// tickerDetailsUpdateLoop continually updates each tickers details. -func (t *Leader) tickerDetailsUpdateLoop(ctx context.Context) error { - timer1 := time.NewTicker(500 * time.Second) // every 5min - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer1.C: - if err := t.refreshTickerDetails(ctx, false); err != nil { - logrus.WithError(err).Error("Unable to update ticker details.") - // We probably don't want to completely exit if ever 1 API call fails. - // return err - } - } - } -} diff --git a/leader/screen-client.go b/leader/screen-client.go deleted file mode 100644 index 66a84bf..0000000 --- a/leader/screen-client.go +++ /dev/null @@ -1,92 +0,0 @@ -package leader - -import ( - "errors" - "sort" - - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/sirupsen/logrus" -) - -// UpdateClient is a generic wrapper which is used for all clients which are requesting -// updates be sent to them. -type UpdateClient struct { - Screen *models.Screen - Updates chan *models.Update - Stream models.Leader_JoinClusterServer -} - -// CurrentScreenCluster will take the current clients and create a ScreenCluster model. -func (t *Leader) CurrentScreenCluster() *models.ScreenCluster { - t.RLock() - defer t.RUnlock() - - res := &models.ScreenCluster{} - res.Settings = t.PresentationSettings - - for _, client := range t.Clients { - res.Screens = append(res.Screens, client.Screen) - } - - return res -} - -func (t *Leader) addScreenToCluster(screenClient *UpdateClient) { - // Add the client and sort them (asc). - t.Lock() - t.Clients = append(t.Clients, screenClient) - sort.Sort(UpdateClientSlice(t.Clients)) - t.Unlock() - - // Update the cluster - t.Updates <- &models.Update{ - UpdateType: int32(models.UpdateTypeCluster), - ScreenCluster: t.CurrentScreenCluster(), - } -} - -func (t *Leader) removeScreenFromCluster(screen *UpdateClient) error { - t.Lock() - - // Find index of screen. - screenIndex := -1 - for i, sc := range t.Clients { - if sc.Screen.UUID == screen.Screen.UUID { - screenIndex = i - } - } - - // We didn't find this client?? - if screenIndex == -1 { - t.Unlock() - return errors.New("unable to find screen when attempting to remove it") - } - - // Remove the element from the slice. - t.Clients[screenIndex] = t.Clients[len(t.Clients)-1] - t.Clients[len(t.Clients)-1] = nil - t.Clients = t.Clients[:len(t.Clients)-1] - - // Re-sort. - sort.Sort(UpdateClientSlice(t.Clients)) - - t.Unlock() - - // Close the clients updates channel. - close(screen.Updates) - - logrus.WithFields(logrus.Fields{ - "uuid": screen.Screen.UUID, - "index": screen.Screen.Index, - "width": screen.Screen.Width, - "height": screen.Screen.Height, - }).Info("Removed screen to cluster.") - - // Update the cluster - t.Updates <- &models.Update{ - UpdateType: int32(models.UpdateTypeCluster), - ScreenCluster: t.CurrentScreenCluster(), - } - - return nil -} diff --git a/leader/screen-update.go b/leader/screen-update.go deleted file mode 100644 index 6ba7696..0000000 --- a/leader/screen-update.go +++ /dev/null @@ -1,45 +0,0 @@ -package leader - -import ( - "context" - "errors" - - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/sirupsen/logrus" -) - -// UpdatePresentationSettings updates the presentation settings of the cluster and sends out an -// update to all clients. -func (t *Leader) UpdateScreen(ctx context.Context, newScreenSettings *models.Screen) (*models.Screen, error) { - logrus.WithFields(logrus.Fields{ - "UUID": newScreenSettings.UUID, - "width": newScreenSettings.Width, - "height": newScreenSettings.Height, - "index": newScreenSettings.Index, - }).Debug("Update presentation settings..") - - didFind := false - t.Lock() - for _, client := range t.Clients { - // Find the screen we want to update - if client.Screen.UUID == newScreenSettings.UUID { - client.Screen = newScreenSettings - didFind = true - break - } - } - t.Unlock() - - // Couldn't find correct screen to update. - if !didFind { - return nil, errors.New("unable to find screen to update with given UUID") - } - - // Update the cluster - t.Updates <- &models.Update{ - UpdateType: int32(models.UpdateTypeCluster), - ScreenCluster: t.CurrentScreenCluster(), - } - - return newScreenSettings, nil -} diff --git a/leader/tickers.go b/leader/tickers.go deleted file mode 100644 index dc6d5ca..0000000 --- a/leader/tickers.go +++ /dev/null @@ -1,96 +0,0 @@ -package leader - -import ( - "context" - "fmt" - "time" - - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/sirupsen/logrus" -) - -func (t *Leader) refreshTickerAggs(ctx context.Context) error { - for _, ticker := range t.Tickers { - if ctx.Err() != nil { - return ctx.Err() - } - - // Each call shouldn't take more than 10sec. - timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - // Get the agg data. - today := getCurrentOrPreviousWeekday(time.Now()) - aggs, err := t.DataClient.GetTickerTodayAggs(timeoutCtx, today, ticker.Ticker, 10) - if err != nil { - return fmt.Errorf("unable to get todays aggs for ticker: %w", err) - } - - logrus.WithFields(logrus.Fields{ - "count": len(aggs), - "ticker": ticker.Ticker, - }).Debug("Got aggregates") - - // TODO: Normalize the aggregates for a time window. - // We want gaps in the agg bars to be filled to convery an accurate - // representation of time. - - // This ticker actually has changes - if len(ticker.Aggs) != len(aggs) { - // Lock and update ticker data. - t.Lock() - ticker.Aggs = aggs - t.Unlock() - t.Updates <- &models.Update{ - UpdateType: int32(models.UpdateTypeTickerUpdate), - Ticker: ticker, - } - } - - } - - return nil -} - -func getCurrentOrPreviousWeekday(today time.Time) time.Time { - weekday := today.Weekday() - if weekday == time.Sunday || weekday == time.Saturday { - // Go back a day - today = today.AddDate(0, 0, -1) - return getCurrentOrPreviousWeekday(today) - } - return today -} - -func (t *Leader) refreshTickerDetails(ctx context.Context, firstRun bool) error { - for _, ticker := range t.Tickers { - if ctx.Err() != nil { - return ctx.Err() - } - - // Total shouldn't take more than 15sec. - timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Second) - defer cancel() - - // Get details. - tickerDetails, err := t.DataClient.LoadTickerData(timeoutCtx, ticker.Ticker) - if err != nil { - return err - } - - // Update with details - t.Lock() - ticker.CompanyName = tickerDetails.CompanyName - ticker.PreviousClosePrice = tickerDetails.PreviousClosePrice - ticker.OutstandingShares = tickerDetails.OutstandingShares - if firstRun { - ticker.Price = tickerDetails.Price - } - t.Unlock() - t.Updates <- &models.Update{ - UpdateType: int32(models.UpdateTypeTickerUpdate), - Ticker: ticker, - } - } - return nil -} diff --git a/leader/update.go b/leader/update.go deleted file mode 100644 index 2d31a67..0000000 --- a/leader/update.go +++ /dev/null @@ -1,25 +0,0 @@ -package leader - -import ( - "context" - - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/sirupsen/logrus" -) - -// UpdatePresentationSettings updates the presentation settings of the cluster and sends out an -// update to all clients. -func (t *Leader) UpdatePresentationSettings(ctx context.Context, newSettings *models.PresentationSettings) (*models.PresentationSettings, error) { - logrus.Debug("Update presentation settings..", newSettings) - - t.Lock() - t.PresentationSettings = newSettings - t.Unlock() - - t.Updates <- &models.Update{ - UpdateType: int32(models.UpdatePresentationSettings), - PresentationSettings: t.PresentationSettings, - } - - return t.PresentationSettings, nil -} diff --git a/leader/utils.go b/leader/utils.go deleted file mode 100644 index 47b1513..0000000 --- a/leader/utils.go +++ /dev/null @@ -1,20 +0,0 @@ -package leader - -import "github.com/massive-com/go-app-ticker-wall/v2/models" - -// UpdateClientSlice is sortable. fancy. -type UpdateClientSlice []*UpdateClient - -func (a UpdateClientSlice) Len() int { return len(a) } -func (a UpdateClientSlice) Swap(i, j int) { a[i], a[j] = a[j], a[i] } -func (a UpdateClientSlice) Less(i, j int) bool { return a[i].Screen.Index < a[j].Screen.Index } - -// constructRGBA is a helper which turns our env variables ( map of string -> int32 ) into a struct. -func constructRGBA(colorMap map[string]int32) *models.RGBA { - return &models.RGBA{ - Red: colorMap["red"], - Green: colorMap["green"], - Blue: colorMap["blue"], - Alpha: colorMap["alpha"], - } -} diff --git a/massive_client/client.go b/massive_client/client.go deleted file mode 100644 index bd92f2a..0000000 --- a/massive_client/client.go +++ /dev/null @@ -1,159 +0,0 @@ -package massive - -import ( - "context" - "time" - - "github.com/gorilla/websocket" - massive "github.com/massive-com/client-go/v2/rest" - massive_models "github.com/massive-com/client-go/v2/rest/models" - massivews "github.com/massive-com/client-go/v2/websocket" - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/sirupsen/logrus" -) - -// We SERIOUSLY need our own Go library... wtf lol -// This library is awful and is a stop gap until we have a client library. -// It also reaches across modules, and does other bad things. -// Shame... Shame... Shame... - -// bufferedChannelSize defines how many items we buffer internally before we start blocking. -const bufferedChannelSize = 10_000 - -// company is the metadata about a company. -type company struct { - CompanyName string `json:"name"` - OutstandingShares int64 `json:"outstanding_shares"` -} - -type Client struct { - PriceUpdates chan *models.PriceUpdate - perTickUpdates bool - wsClient *websocket.Conn - - restClient *massive.Client - websocketClient *massivews.Client -} - -// NewClient creates a new massive API client. -func NewClient(apiKey string, perTickUpdate bool) (*Client, error) { - wsclient, err := massivews.New(massivews.Config{ - APIKey: apiKey, - Feed: massivews.RealTime, - Market: massivews.Stocks, - }) - - if err != nil { - return nil, err - } - - return &Client{ - PriceUpdates: make(chan *models.PriceUpdate, bufferedChannelSize), - perTickUpdates: perTickUpdate, - restClient: massive.New(apiKey), - websocketClient: wsclient, - }, nil -} - -func (c *Client) LoadTickerData(ctx context.Context, tickerSymbol string) (*models.Ticker, error) { - ctx, cancel := context.WithTimeout(ctx, time.Second*5) - defer cancel() - - logrus.WithField("ticker", tickerSymbol).Debug("Loading ticker data..") - ticker := &models.Ticker{ - Ticker: tickerSymbol, - } - - // Get Yesterdays Price - previousClosePrice, err := c.GetTickerYesterdaysClose(ctx, tickerSymbol) - if err != nil { - return nil, err - } - ticker.PreviousClosePrice = previousClosePrice - - // Get Current Price - currentPrice, err := c.GetTickerCurrentPrice(ctx, tickerSymbol) - if err != nil { - return nil, err - } - ticker.Price = currentPrice - - // Get company Info - companyInfo, err := c.GetCompanyDetails(ctx, tickerSymbol) - if err != nil { - return nil, err - } - ticker.CompanyName = companyInfo.CompanyName - ticker.OutstandingShares = companyInfo.OutstandingShares - - return ticker, nil -} - -func (c *Client) GetTickerTodayAggs(ctx context.Context, t time.Time, ticker string, rangeSize int) ([]*models.Agg, error) { - loc, _ := time.LoadLocation("America/New_York") - - // Start at 9am instead of 930am because sometimes pre market is significant to the charts. - openTime := time.Date(t.Year(), t.Month(), t.Day(), 9, 0, 0, 0, loc) - closeTime := time.Date(t.Year(), t.Month(), t.Day(), 16, 30, 0, 0, loc) - - aggsParams := massive_models.GetAggsParams{ - Ticker: ticker, - Multiplier: rangeSize, - Timespan: massive_models.Minute, - From: massive_models.Millis(openTime), - To: massive_models.Millis(closeTime), - }.WithLimit(int(closeTime.Sub(openTime) / time.Minute)) - - resp, err := c.restClient.GetAggs(ctx, aggsParams) - if err != nil { - return nil, err - } - - // Transform our massive.com aggregates into the "model" aggregates. - results := make([]*models.Agg, 0, len(resp.Results)) - for _, agg := range resp.Results { - results = append(results, &models.Agg{ - Price: agg.Close, - Volume: int32(agg.Volume), - Timestamp: time.Time(agg.Timestamp).UnixMilli(), - }) - } - - return results, nil -} - -func (c *Client) GetTickerCurrentPrice(ctx context.Context, ticker string) (float64, error) { - resp, err := c.restClient.GetLastTrade(ctx, &massive_models.GetLastTradeParams{Ticker: ticker}) - if err != nil { - return 0, err - } - - return resp.Results.Price, nil -} - -func (c *Client) GetCompanyDetails(ctx context.Context, ticker string) (*company, error) { - resp, err := c.restClient.GetTickerDetails(ctx, &massive_models.GetTickerDetailsParams{Ticker: ticker}) - if err != nil { - return nil, err - } - - return &company{ - CompanyName: resp.Results.Name, - OutstandingShares: resp.Results.WeightedSharesOutstanding, - }, nil -} - -// GetTickerYesterdaysClose is the previous days close price. Takes into account weekends, holidays. -// This should always return a price for a ticker if it has ever traded previously. -func (c *Client) GetTickerYesterdaysClose(ctx context.Context, ticker string) (float64, error) { - resp, err := c.restClient.AggsClient.GetPreviousCloseAgg(ctx, &massive_models.GetPreviousCloseAggParams{Ticker: ticker}) - if err != nil { - return 0, err - } - - if len(resp.Results) < 1 { - return 0, nil - } - - return resp.Results[0].Close, nil -} diff --git a/massive_client/ws.go b/massive_client/ws.go deleted file mode 100644 index 87d0ed4..0000000 --- a/massive_client/ws.go +++ /dev/null @@ -1,54 +0,0 @@ -package massive - -import ( - "context" - "fmt" - - massivews "github.com/massive-com/client-go/v2/websocket" - massivews_models "github.com/massive-com/client-go/v2/websocket/models" - - "github.com/massive-com/go-app-ticker-wall/v2/models" -) - -func (c *Client) ListenForTickerUpdates(ctx context.Context, tickers []string) error { - if err := c.websocketClient.Connect(); err != nil { - return fmt.Errorf("connect websocket: %w", err) - } - - defer c.websocketClient.Close() - - topic := massivews.StocksSecAggs - if c.perTickUpdates { - topic = massivews.StocksTrades - } - - if err := c.websocketClient.Subscribe(topic, tickers...); err != nil { - return fmt.Errorf("subscribe websocket: %w", err) - } - - for { - select { - case <-ctx.Done(): - return nil - case msg, more := <-c.websocketClient.Output(): - if !more { - return nil - } - - switch msg.(type) { - case massivews_models.EquityAgg: - agg := msg.(massivews_models.EquityAgg) - c.PriceUpdates <- &models.PriceUpdate{ - Ticker: agg.Symbol, - Price: agg.Close, - } - case massivews_models.EquityTrade: - trade := msg.(massivews_models.EquityTrade) - c.PriceUpdates <- &models.PriceUpdate{ - Ticker: trade.Symbol, - Price: trade.Price, - } - } - } - } -} diff --git a/misc/design.jpg b/misc/design.jpg deleted file mode 100644 index 67c1005..0000000 Binary files a/misc/design.jpg and /dev/null differ diff --git a/misc/ticker-wall.gif b/misc/ticker-wall.gif index 6eaaca1..6a4306e 100644 Binary files a/misc/ticker-wall.gif and b/misc/ticker-wall.gif differ diff --git a/models/constants.go b/models/constants.go deleted file mode 100644 index 8096148..0000000 --- a/models/constants.go +++ /dev/null @@ -1,49 +0,0 @@ -package models - -// UpdateType is a constant used to define the contents of an update. -type UpdateType int32 - -const ( - // UpdateTypeUnknown is not a known update type. - UpdateTypeUnknown UpdateType = 0 - // UpdateTypeCluster updates information about the screen cluster. - UpdateTypeCluster UpdateType = 1 - // UpdateTypeTickerAdded adds a new ticker to our list. - UpdateTypeTickerAdded UpdateType = 2 - // UpdateTypeTickerRemoved a ticker has been removed from the list. - UpdateTypeTickerRemoved UpdateType = 3 - // UpdateTypeTickerUpdate a ticker has been updated. - UpdateTypeTickerUpdate UpdateType = 4 - // UpdateTypeAnnouncement an announcement has been created. - UpdateTypeAnnouncement UpdateType = 5 - // UpdateTypePrice means a tickers price has been updated. - UpdateTypePrice UpdateType = 6 - // UpdatePresentationSettings means presentation settings have been updated. - UpdatePresentationSettings UpdateType = 7 -) - -// AnnouncementType is used to signify the type of announcement / alert. Different announcement types behave differently. -type AnnouncementType int32 - -const ( - // AnnouncementTypeInfo is a normal announcement. - AnnouncementTypeInfo AnnouncementType = 0 - // AnnouncementTypeDanger is an announcement with 'Danger' colors. - AnnouncementTypeDanger AnnouncementType = 1 - // AnnouncementTypeSuccess is an announcement with 'Success' colors. - AnnouncementTypeSuccess AnnouncementType = 2 -) - -// AnnouncementAnimation are the different animation options available for an announcement. -type AnnouncementAnimation int32 - -const ( - // AnnouncementAnimationElastic uses the Elastic animation pattern. - AnnouncementAnimationElastic AnnouncementAnimation = 0 - // AnnouncementAnimationBounce uses the Bounce animation pattern. - AnnouncementAnimationBounce AnnouncementAnimation = 1 - // AnnouncementAnimationEase uses the Easing animation pattern. - AnnouncementAnimationEase AnnouncementAnimation = 2 - // AnnouncementAnimationBack uses the Back animation pattern. - AnnouncementAnimationBack AnnouncementAnimation = 3 -) diff --git a/models/models.pb.go b/models/models.pb.go deleted file mode 100644 index 3463d29..0000000 --- a/models/models.pb.go +++ /dev/null @@ -1,1524 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.27.1 -// protoc v3.17.3 -// source: models.proto - -package models - -import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// Ticker is used to update a tickers information ( leader -> follower ). -type Ticker struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Ticker string `protobuf:"bytes,1,opt,name=Ticker,proto3" json:"Ticker,omitempty"` - CompanyName string `protobuf:"bytes,2,opt,name=CompanyName,proto3" json:"CompanyName,omitempty"` - OutstandingShares int64 `protobuf:"varint,3,opt,name=OutstandingShares,proto3" json:"OutstandingShares,omitempty"` - Price float64 `protobuf:"fixed64,4,opt,name=Price,proto3" json:"Price,omitempty"` - MarketCap float64 `protobuf:"fixed64,5,opt,name=MarketCap,proto3" json:"MarketCap,omitempty"` - PriceChangePercentage float64 `protobuf:"fixed64,6,opt,name=PriceChangePercentage,proto3" json:"PriceChangePercentage,omitempty"` - PreviousClosePrice float64 `protobuf:"fixed64,7,opt,name=PreviousClosePrice,proto3" json:"PreviousClosePrice,omitempty"` - Index int32 `protobuf:"varint,8,opt,name=Index,proto3" json:"Index,omitempty"` - Img int32 `protobuf:"varint,9,opt,name=Img,proto3" json:"Img,omitempty"` - ImgData []byte `protobuf:"bytes,10,opt,name=ImgData,proto3" json:"ImgData,omitempty"` - Aggs []*Agg `protobuf:"bytes,11,rep,name=Aggs,proto3" json:"Aggs,omitempty"` -} - -func (x *Ticker) Reset() { - *x = Ticker{} - if protoimpl.UnsafeEnabled { - mi := &file_models_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *Ticker) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Ticker) ProtoMessage() {} - -func (x *Ticker) ProtoReflect() protoreflect.Message { - mi := &file_models_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Ticker.ProtoReflect.Descriptor instead. -func (*Ticker) Descriptor() ([]byte, []int) { - return file_models_proto_rawDescGZIP(), []int{0} -} - -func (x *Ticker) GetTicker() string { - if x != nil { - return x.Ticker - } - return "" -} - -func (x *Ticker) GetCompanyName() string { - if x != nil { - return x.CompanyName - } - return "" -} - -func (x *Ticker) GetOutstandingShares() int64 { - if x != nil { - return x.OutstandingShares - } - return 0 -} - -func (x *Ticker) GetPrice() float64 { - if x != nil { - return x.Price - } - return 0 -} - -func (x *Ticker) GetMarketCap() float64 { - if x != nil { - return x.MarketCap - } - return 0 -} - -func (x *Ticker) GetPriceChangePercentage() float64 { - if x != nil { - return x.PriceChangePercentage - } - return 0 -} - -func (x *Ticker) GetPreviousClosePrice() float64 { - if x != nil { - return x.PreviousClosePrice - } - return 0 -} - -func (x *Ticker) GetIndex() int32 { - if x != nil { - return x.Index - } - return 0 -} - -func (x *Ticker) GetImg() int32 { - if x != nil { - return x.Img - } - return 0 -} - -func (x *Ticker) GetImgData() []byte { - if x != nil { - return x.ImgData - } - return nil -} - -func (x *Ticker) GetAggs() []*Agg { - if x != nil { - return x.Aggs - } - return nil -} - -// Agg is an individual aggregate used to generate graphs. -type Agg struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Price float64 `protobuf:"fixed64,1,opt,name=Price,proto3" json:"Price,omitempty"` - Volume int32 `protobuf:"varint,2,opt,name=Volume,proto3" json:"Volume,omitempty"` - Timestamp int64 `protobuf:"varint,3,opt,name=Timestamp,proto3" json:"Timestamp,omitempty"` -} - -func (x *Agg) Reset() { - *x = Agg{} - if protoimpl.UnsafeEnabled { - mi := &file_models_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *Agg) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Agg) ProtoMessage() {} - -func (x *Agg) ProtoReflect() protoreflect.Message { - mi := &file_models_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Agg.ProtoReflect.Descriptor instead. -func (*Agg) Descriptor() ([]byte, []int) { - return file_models_proto_rawDescGZIP(), []int{1} -} - -func (x *Agg) GetPrice() float64 { - if x != nil { - return x.Price - } - return 0 -} - -func (x *Agg) GetVolume() int32 { - if x != nil { - return x.Volume - } - return 0 -} - -func (x *Agg) GetTimestamp() int64 { - if x != nil { - return x.Timestamp - } - return 0 -} - -// PriceUpdate is the message sent when a price updates for a ticker. -type PriceUpdate struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Ticker string `protobuf:"bytes,1,opt,name=Ticker,proto3" json:"Ticker,omitempty"` - Price float64 `protobuf:"fixed64,2,opt,name=Price,proto3" json:"Price,omitempty"` -} - -func (x *PriceUpdate) Reset() { - *x = PriceUpdate{} - if protoimpl.UnsafeEnabled { - mi := &file_models_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *PriceUpdate) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PriceUpdate) ProtoMessage() {} - -func (x *PriceUpdate) ProtoReflect() protoreflect.Message { - mi := &file_models_proto_msgTypes[2] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PriceUpdate.ProtoReflect.Descriptor instead. -func (*PriceUpdate) Descriptor() ([]byte, []int) { - return file_models_proto_rawDescGZIP(), []int{2} -} - -func (x *PriceUpdate) GetTicker() string { - if x != nil { - return x.Ticker - } - return "" -} - -func (x *PriceUpdate) GetPrice() float64 { - if x != nil { - return x.Price - } - return 0 -} - -// Announcement is used to display a special message on the display. -type Announcement struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Message string `protobuf:"bytes,1,opt,name=Message,proto3" json:"Message,omitempty"` - AnnouncementType int32 `protobuf:"varint,2,opt,name=AnnouncementType,proto3" json:"AnnouncementType,omitempty"` - ShowAtTimestampMS int64 `protobuf:"varint,3,opt,name=ShowAtTimestampMS,proto3" json:"ShowAtTimestampMS,omitempty"` - LifespanMS int64 `protobuf:"varint,4,opt,name=LifespanMS,proto3" json:"LifespanMS,omitempty"` - Animation int32 `protobuf:"varint,5,opt,name=Animation,proto3" json:"Animation,omitempty"` -} - -func (x *Announcement) Reset() { - *x = Announcement{} - if protoimpl.UnsafeEnabled { - mi := &file_models_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *Announcement) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Announcement) ProtoMessage() {} - -func (x *Announcement) ProtoReflect() protoreflect.Message { - mi := &file_models_proto_msgTypes[3] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Announcement.ProtoReflect.Descriptor instead. -func (*Announcement) Descriptor() ([]byte, []int) { - return file_models_proto_rawDescGZIP(), []int{3} -} - -func (x *Announcement) GetMessage() string { - if x != nil { - return x.Message - } - return "" -} - -func (x *Announcement) GetAnnouncementType() int32 { - if x != nil { - return x.AnnouncementType - } - return 0 -} - -func (x *Announcement) GetShowAtTimestampMS() int64 { - if x != nil { - return x.ShowAtTimestampMS - } - return 0 -} - -func (x *Announcement) GetLifespanMS() int64 { - if x != nil { - return x.LifespanMS - } - return 0 -} - -func (x *Announcement) GetAnimation() int32 { - if x != nil { - return x.Animation - } - return 0 -} - -// Screen contains all screen information about an individual screen. -type Screen struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - UUID string `protobuf:"bytes,1,opt,name=UUID,proto3" json:"UUID,omitempty"` - Width int32 `protobuf:"varint,2,opt,name=Width,proto3" json:"Width,omitempty"` - Height int32 `protobuf:"varint,3,opt,name=Height,proto3" json:"Height,omitempty"` - Index int32 `protobuf:"varint,4,opt,name=Index,proto3" json:"Index,omitempty"` -} - -func (x *Screen) Reset() { - *x = Screen{} - if protoimpl.UnsafeEnabled { - mi := &file_models_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *Screen) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Screen) ProtoMessage() {} - -func (x *Screen) ProtoReflect() protoreflect.Message { - mi := &file_models_proto_msgTypes[4] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Screen.ProtoReflect.Descriptor instead. -func (*Screen) Descriptor() ([]byte, []int) { - return file_models_proto_rawDescGZIP(), []int{4} -} - -func (x *Screen) GetUUID() string { - if x != nil { - return x.UUID - } - return "" -} - -func (x *Screen) GetWidth() int32 { - if x != nil { - return x.Width - } - return 0 -} - -func (x *Screen) GetHeight() int32 { - if x != nil { - return x.Height - } - return 0 -} - -func (x *Screen) GetIndex() int32 { - if x != nil { - return x.Index - } - return 0 -} - -// ScreenCluster contains information about the whole screen cluster. -type ScreenCluster struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Settings *PresentationSettings `protobuf:"bytes,1,opt,name=Settings,proto3" json:"Settings,omitempty"` - Screens []*Screen `protobuf:"bytes,2,rep,name=Screens,proto3" json:"Screens,omitempty"` -} - -func (x *ScreenCluster) Reset() { - *x = ScreenCluster{} - if protoimpl.UnsafeEnabled { - mi := &file_models_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ScreenCluster) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ScreenCluster) ProtoMessage() {} - -func (x *ScreenCluster) ProtoReflect() protoreflect.Message { - mi := &file_models_proto_msgTypes[5] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ScreenCluster.ProtoReflect.Descriptor instead. -func (*ScreenCluster) Descriptor() ([]byte, []int) { - return file_models_proto_rawDescGZIP(), []int{5} -} - -func (x *ScreenCluster) GetSettings() *PresentationSettings { - if x != nil { - return x.Settings - } - return nil -} - -func (x *ScreenCluster) GetScreens() []*Screen { - if x != nil { - return x.Screens - } - return nil -} - -type PresentationSettings struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - TickerBoxWidth int32 `protobuf:"varint,1,opt,name=TickerBoxWidth,proto3" json:"TickerBoxWidth,omitempty"` - ScrollSpeed int32 `protobuf:"varint,2,opt,name=ScrollSpeed,proto3" json:"ScrollSpeed,omitempty"` - UpColor *RGBA `protobuf:"bytes,3,opt,name=UpColor,proto3" json:"UpColor,omitempty"` - DownColor *RGBA `protobuf:"bytes,4,opt,name=DownColor,proto3" json:"DownColor,omitempty"` - BGColor *RGBA `protobuf:"bytes,5,opt,name=BGColor,proto3" json:"BGColor,omitempty"` - FontColor *RGBA `protobuf:"bytes,6,opt,name=FontColor,proto3" json:"FontColor,omitempty"` - TickerBoxBGColor *RGBA `protobuf:"bytes,7,opt,name=TickerBoxBGColor,proto3" json:"TickerBoxBGColor,omitempty"` - ShowLogos bool `protobuf:"varint,8,opt,name=ShowLogos,proto3" json:"ShowLogos,omitempty"` - ShowFPS bool `protobuf:"varint,9,opt,name=ShowFPS,proto3" json:"ShowFPS,omitempty"` - AnimationDurationMS int32 `protobuf:"varint,10,opt,name=AnimationDurationMS,proto3" json:"AnimationDurationMS,omitempty"` - PerTickUpdates bool `protobuf:"varint,11,opt,name=PerTickUpdates,proto3" json:"PerTickUpdates,omitempty"` -} - -func (x *PresentationSettings) Reset() { - *x = PresentationSettings{} - if protoimpl.UnsafeEnabled { - mi := &file_models_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *PresentationSettings) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PresentationSettings) ProtoMessage() {} - -func (x *PresentationSettings) ProtoReflect() protoreflect.Message { - mi := &file_models_proto_msgTypes[6] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PresentationSettings.ProtoReflect.Descriptor instead. -func (*PresentationSettings) Descriptor() ([]byte, []int) { - return file_models_proto_rawDescGZIP(), []int{6} -} - -func (x *PresentationSettings) GetTickerBoxWidth() int32 { - if x != nil { - return x.TickerBoxWidth - } - return 0 -} - -func (x *PresentationSettings) GetScrollSpeed() int32 { - if x != nil { - return x.ScrollSpeed - } - return 0 -} - -func (x *PresentationSettings) GetUpColor() *RGBA { - if x != nil { - return x.UpColor - } - return nil -} - -func (x *PresentationSettings) GetDownColor() *RGBA { - if x != nil { - return x.DownColor - } - return nil -} - -func (x *PresentationSettings) GetBGColor() *RGBA { - if x != nil { - return x.BGColor - } - return nil -} - -func (x *PresentationSettings) GetFontColor() *RGBA { - if x != nil { - return x.FontColor - } - return nil -} - -func (x *PresentationSettings) GetTickerBoxBGColor() *RGBA { - if x != nil { - return x.TickerBoxBGColor - } - return nil -} - -func (x *PresentationSettings) GetShowLogos() bool { - if x != nil { - return x.ShowLogos - } - return false -} - -func (x *PresentationSettings) GetShowFPS() bool { - if x != nil { - return x.ShowFPS - } - return false -} - -func (x *PresentationSettings) GetAnimationDurationMS() int32 { - if x != nil { - return x.AnimationDurationMS - } - return 0 -} - -func (x *PresentationSettings) GetPerTickUpdates() bool { - if x != nil { - return x.PerTickUpdates - } - return false -} - -// Update encapsulates different update messages. -type Update struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - UpdateType int32 `protobuf:"varint,1,opt,name=UpdateType,proto3" json:"UpdateType,omitempty"` - PriceUpdate *PriceUpdate `protobuf:"bytes,2,opt,name=PriceUpdate,proto3" json:"PriceUpdate,omitempty"` - Announcement *Announcement `protobuf:"bytes,3,opt,name=Announcement,proto3" json:"Announcement,omitempty"` - ScreenCluster *ScreenCluster `protobuf:"bytes,4,opt,name=ScreenCluster,proto3" json:"ScreenCluster,omitempty"` - Ticker *Ticker `protobuf:"bytes,5,opt,name=Ticker,proto3" json:"Ticker,omitempty"` - PresentationSettings *PresentationSettings `protobuf:"bytes,6,opt,name=PresentationSettings,proto3" json:"PresentationSettings,omitempty"` -} - -func (x *Update) Reset() { - *x = Update{} - if protoimpl.UnsafeEnabled { - mi := &file_models_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *Update) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Update) ProtoMessage() {} - -func (x *Update) ProtoReflect() protoreflect.Message { - mi := &file_models_proto_msgTypes[7] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Update.ProtoReflect.Descriptor instead. -func (*Update) Descriptor() ([]byte, []int) { - return file_models_proto_rawDescGZIP(), []int{7} -} - -func (x *Update) GetUpdateType() int32 { - if x != nil { - return x.UpdateType - } - return 0 -} - -func (x *Update) GetPriceUpdate() *PriceUpdate { - if x != nil { - return x.PriceUpdate - } - return nil -} - -func (x *Update) GetAnnouncement() *Announcement { - if x != nil { - return x.Announcement - } - return nil -} - -func (x *Update) GetScreenCluster() *ScreenCluster { - if x != nil { - return x.ScreenCluster - } - return nil -} - -func (x *Update) GetTicker() *Ticker { - if x != nil { - return x.Ticker - } - return nil -} - -func (x *Update) GetPresentationSettings() *PresentationSettings { - if x != nil { - return x.PresentationSettings - } - return nil -} - -// RGBA is how we represent colors. -type RGBA struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Red int32 `protobuf:"varint,1,opt,name=Red,proto3" json:"Red,omitempty"` - Green int32 `protobuf:"varint,2,opt,name=Green,proto3" json:"Green,omitempty"` - Blue int32 `protobuf:"varint,3,opt,name=Blue,proto3" json:"Blue,omitempty"` - Alpha int32 `protobuf:"varint,4,opt,name=Alpha,proto3" json:"Alpha,omitempty"` -} - -func (x *RGBA) Reset() { - *x = RGBA{} - if protoimpl.UnsafeEnabled { - mi := &file_models_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *RGBA) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RGBA) ProtoMessage() {} - -func (x *RGBA) ProtoReflect() protoreflect.Message { - mi := &file_models_proto_msgTypes[8] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RGBA.ProtoReflect.Descriptor instead. -func (*RGBA) Descriptor() ([]byte, []int) { - return file_models_proto_rawDescGZIP(), []int{8} -} - -func (x *RGBA) GetRed() int32 { - if x != nil { - return x.Red - } - return 0 -} - -func (x *RGBA) GetGreen() int32 { - if x != nil { - return x.Green - } - return 0 -} - -func (x *RGBA) GetBlue() int32 { - if x != nil { - return x.Blue - } - return 0 -} - -func (x *RGBA) GetAlpha() int32 { - if x != nil { - return x.Alpha - } - return 0 -} - -// Group of Tickers -type Tickers struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Tickers []*Ticker `protobuf:"bytes,1,rep,name=Tickers,proto3" json:"Tickers,omitempty"` -} - -func (x *Tickers) Reset() { - *x = Tickers{} - if protoimpl.UnsafeEnabled { - mi := &file_models_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *Tickers) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Tickers) ProtoMessage() {} - -func (x *Tickers) ProtoReflect() protoreflect.Message { - mi := &file_models_proto_msgTypes[9] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Tickers.ProtoReflect.Descriptor instead. -func (*Tickers) Descriptor() ([]byte, []int) { - return file_models_proto_rawDescGZIP(), []int{9} -} - -func (x *Tickers) GetTickers() []*Ticker { - if x != nil { - return x.Tickers - } - return nil -} - -type Empty struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields -} - -func (x *Empty) Reset() { - *x = Empty{} - if protoimpl.UnsafeEnabled { - mi := &file_models_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *Empty) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Empty) ProtoMessage() {} - -func (x *Empty) ProtoReflect() protoreflect.Message { - mi := &file_models_proto_msgTypes[10] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Empty.ProtoReflect.Descriptor instead. -func (*Empty) Descriptor() ([]byte, []int) { - return file_models_proto_rawDescGZIP(), []int{10} -} - -var File_models_proto protoreflect.FileDescriptor - -var file_models_proto_rawDesc = []byte{ - 0x0a, 0x0c, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x06, - 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x22, 0xed, 0x02, 0x0a, 0x06, 0x54, 0x69, 0x63, 0x6b, 0x65, - 0x72, 0x12, 0x16, 0x0a, 0x06, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x12, 0x20, 0x0a, 0x0b, 0x43, 0x6f, 0x6d, - 0x70, 0x61, 0x6e, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, - 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x6e, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x2c, 0x0a, 0x11, 0x4f, - 0x75, 0x74, 0x73, 0x74, 0x61, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x53, 0x68, 0x61, 0x72, 0x65, 0x73, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x4f, 0x75, 0x74, 0x73, 0x74, 0x61, 0x6e, 0x64, - 0x69, 0x6e, 0x67, 0x53, 0x68, 0x61, 0x72, 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x50, 0x72, 0x69, - 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x01, 0x52, 0x05, 0x50, 0x72, 0x69, 0x63, 0x65, 0x12, - 0x1c, 0x0a, 0x09, 0x4d, 0x61, 0x72, 0x6b, 0x65, 0x74, 0x43, 0x61, 0x70, 0x18, 0x05, 0x20, 0x01, - 0x28, 0x01, 0x52, 0x09, 0x4d, 0x61, 0x72, 0x6b, 0x65, 0x74, 0x43, 0x61, 0x70, 0x12, 0x34, 0x0a, - 0x15, 0x50, 0x72, 0x69, 0x63, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x50, 0x65, 0x72, 0x63, - 0x65, 0x6e, 0x74, 0x61, 0x67, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x01, 0x52, 0x15, 0x50, 0x72, - 0x69, 0x63, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x50, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, - 0x61, 0x67, 0x65, 0x12, 0x2e, 0x0a, 0x12, 0x50, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x43, - 0x6c, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x69, 0x63, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, - 0x12, 0x50, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x50, 0x72, - 0x69, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x05, 0x52, 0x05, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x10, 0x0a, 0x03, 0x49, 0x6d, 0x67, - 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x49, 0x6d, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x49, - 0x6d, 0x67, 0x44, 0x61, 0x74, 0x61, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x49, 0x6d, - 0x67, 0x44, 0x61, 0x74, 0x61, 0x12, 0x1f, 0x0a, 0x04, 0x41, 0x67, 0x67, 0x73, 0x18, 0x0b, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x41, 0x67, 0x67, - 0x52, 0x04, 0x41, 0x67, 0x67, 0x73, 0x22, 0x51, 0x0a, 0x03, 0x41, 0x67, 0x67, 0x12, 0x14, 0x0a, - 0x05, 0x50, 0x72, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x01, 0x52, 0x05, 0x50, 0x72, - 0x69, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x05, 0x52, 0x06, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x54, - 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, - 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0x3b, 0x0a, 0x0b, 0x50, 0x72, 0x69, - 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x54, 0x69, 0x63, 0x6b, - 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x72, - 0x12, 0x14, 0x0a, 0x05, 0x50, 0x72, 0x69, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x01, 0x52, - 0x05, 0x50, 0x72, 0x69, 0x63, 0x65, 0x22, 0xc0, 0x01, 0x0a, 0x0c, 0x41, 0x6e, 0x6e, 0x6f, 0x75, - 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x12, 0x2a, 0x0a, 0x10, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x10, 0x41, 0x6e, 0x6e, - 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x2c, 0x0a, - 0x11, 0x53, 0x68, 0x6f, 0x77, 0x41, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, - 0x4d, 0x53, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x53, 0x68, 0x6f, 0x77, 0x41, 0x74, - 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x4d, 0x53, 0x12, 0x1e, 0x0a, 0x0a, 0x4c, - 0x69, 0x66, 0x65, 0x73, 0x70, 0x61, 0x6e, 0x4d, 0x53, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x0a, 0x4c, 0x69, 0x66, 0x65, 0x73, 0x70, 0x61, 0x6e, 0x4d, 0x53, 0x12, 0x1c, 0x0a, 0x09, 0x41, - 0x6e, 0x69, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, - 0x41, 0x6e, 0x69, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x60, 0x0a, 0x06, 0x53, 0x63, 0x72, - 0x65, 0x65, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x55, 0x55, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x55, 0x55, 0x49, 0x44, 0x12, 0x14, 0x0a, 0x05, 0x57, 0x69, 0x64, 0x74, 0x68, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x57, 0x69, 0x64, 0x74, 0x68, 0x12, 0x16, 0x0a, - 0x06, 0x48, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x48, - 0x65, 0x69, 0x67, 0x68, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x22, 0x73, 0x0a, 0x0d, 0x53, - 0x63, 0x72, 0x65, 0x65, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, 0x38, 0x0a, 0x08, - 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, - 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x08, 0x53, 0x65, - 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x28, 0x0a, 0x07, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, - 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, - 0x2e, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x52, 0x07, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x73, - 0x22, 0xd4, 0x03, 0x0a, 0x14, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x54, 0x69, 0x63, - 0x6b, 0x65, 0x72, 0x42, 0x6f, 0x78, 0x57, 0x69, 0x64, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x05, 0x52, 0x0e, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x42, 0x6f, 0x78, 0x57, 0x69, 0x64, 0x74, - 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x53, 0x63, 0x72, 0x6f, 0x6c, 0x6c, 0x53, 0x70, 0x65, 0x65, 0x64, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x53, 0x63, 0x72, 0x6f, 0x6c, 0x6c, 0x53, 0x70, - 0x65, 0x65, 0x64, 0x12, 0x26, 0x0a, 0x07, 0x55, 0x70, 0x43, 0x6f, 0x6c, 0x6f, 0x72, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x52, 0x47, - 0x42, 0x41, 0x52, 0x07, 0x55, 0x70, 0x43, 0x6f, 0x6c, 0x6f, 0x72, 0x12, 0x2a, 0x0a, 0x09, 0x44, - 0x6f, 0x77, 0x6e, 0x43, 0x6f, 0x6c, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, - 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x52, 0x47, 0x42, 0x41, 0x52, 0x09, 0x44, 0x6f, - 0x77, 0x6e, 0x43, 0x6f, 0x6c, 0x6f, 0x72, 0x12, 0x26, 0x0a, 0x07, 0x42, 0x47, 0x43, 0x6f, 0x6c, - 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, - 0x73, 0x2e, 0x52, 0x47, 0x42, 0x41, 0x52, 0x07, 0x42, 0x47, 0x43, 0x6f, 0x6c, 0x6f, 0x72, 0x12, - 0x2a, 0x0a, 0x09, 0x46, 0x6f, 0x6e, 0x74, 0x43, 0x6f, 0x6c, 0x6f, 0x72, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x52, 0x47, 0x42, 0x41, - 0x52, 0x09, 0x46, 0x6f, 0x6e, 0x74, 0x43, 0x6f, 0x6c, 0x6f, 0x72, 0x12, 0x38, 0x0a, 0x10, 0x54, - 0x69, 0x63, 0x6b, 0x65, 0x72, 0x42, 0x6f, 0x78, 0x42, 0x47, 0x43, 0x6f, 0x6c, 0x6f, 0x72, 0x18, - 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x52, - 0x47, 0x42, 0x41, 0x52, 0x10, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x42, 0x6f, 0x78, 0x42, 0x47, - 0x43, 0x6f, 0x6c, 0x6f, 0x72, 0x12, 0x1c, 0x0a, 0x09, 0x53, 0x68, 0x6f, 0x77, 0x4c, 0x6f, 0x67, - 0x6f, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x53, 0x68, 0x6f, 0x77, 0x4c, 0x6f, - 0x67, 0x6f, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x53, 0x68, 0x6f, 0x77, 0x46, 0x50, 0x53, 0x18, 0x09, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x53, 0x68, 0x6f, 0x77, 0x46, 0x50, 0x53, 0x12, 0x30, 0x0a, - 0x13, 0x41, 0x6e, 0x69, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x4d, 0x53, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, 0x52, 0x13, 0x41, 0x6e, 0x69, 0x6d, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x53, 0x12, - 0x26, 0x0a, 0x0e, 0x50, 0x65, 0x72, 0x54, 0x69, 0x63, 0x6b, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x50, 0x65, 0x72, 0x54, 0x69, 0x63, 0x6b, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x73, 0x22, 0xd0, 0x02, 0x0a, 0x06, 0x55, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, - 0x70, 0x65, 0x12, 0x35, 0x0a, 0x0b, 0x50, 0x72, 0x69, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, - 0x2e, 0x50, 0x72, 0x69, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x0b, 0x50, 0x72, - 0x69, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x38, 0x0a, 0x0c, 0x41, 0x6e, 0x6e, - 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x14, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x0c, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x12, 0x3b, 0x0a, 0x0d, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x43, 0x6c, 0x75, - 0x73, 0x74, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x6f, 0x64, - 0x65, 0x6c, 0x73, 0x2e, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, - 0x72, 0x52, 0x0d, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, - 0x12, 0x26, 0x0a, 0x06, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x0e, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x72, - 0x52, 0x06, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x12, 0x50, 0x0a, 0x14, 0x50, 0x72, 0x65, 0x73, - 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, - 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x74, 0x74, - 0x69, 0x6e, 0x67, 0x73, 0x52, 0x14, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x22, 0x58, 0x0a, 0x04, 0x52, 0x47, - 0x42, 0x41, 0x12, 0x10, 0x0a, 0x03, 0x52, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, - 0x03, 0x52, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x47, 0x72, 0x65, 0x65, 0x6e, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x05, 0x52, 0x05, 0x47, 0x72, 0x65, 0x65, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x42, 0x6c, - 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x42, 0x6c, 0x75, 0x65, 0x12, 0x14, - 0x0a, 0x05, 0x41, 0x6c, 0x70, 0x68, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x41, - 0x6c, 0x70, 0x68, 0x61, 0x22, 0x33, 0x0a, 0x07, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x73, 0x12, - 0x28, 0x0a, 0x07, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x0e, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x72, - 0x52, 0x07, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x73, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, - 0x74, 0x79, 0x32, 0xef, 0x02, 0x0a, 0x06, 0x4c, 0x65, 0x61, 0x64, 0x65, 0x72, 0x12, 0x31, 0x0a, - 0x0b, 0x4a, 0x6f, 0x69, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, 0x0e, 0x2e, 0x6d, - 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x1a, 0x0e, 0x2e, 0x6d, - 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x22, 0x00, 0x30, 0x01, - 0x12, 0x2e, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x73, 0x12, 0x0d, - 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0f, 0x2e, - 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x73, 0x22, 0x00, - 0x12, 0x5a, 0x0a, 0x1a, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, - 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1c, - 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x1a, 0x1c, 0x2e, 0x6d, - 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x22, 0x00, 0x12, 0x38, 0x0a, 0x08, - 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x12, 0x14, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, - 0x73, 0x2e, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x1a, 0x14, - 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x22, 0x00, 0x12, 0x3a, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x53, 0x63, 0x72, - 0x65, 0x65, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, 0x0d, 0x2e, 0x6d, 0x6f, 0x64, - 0x65, 0x6c, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x15, 0x2e, 0x6d, 0x6f, 0x64, 0x65, - 0x6c, 0x73, 0x2e, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, - 0x22, 0x00, 0x12, 0x30, 0x0a, 0x0c, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x63, 0x72, 0x65, - 0x65, 0x6e, 0x12, 0x0e, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x53, 0x63, 0x72, 0x65, - 0x65, 0x6e, 0x1a, 0x0e, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x2e, 0x53, 0x63, 0x72, 0x65, - 0x65, 0x6e, 0x22, 0x00, 0x42, 0x30, 0x5a, 0x2e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, - 0x6f, 0x6d, 0x2f, 0x70, 0x6f, 0x6c, 0x79, 0x67, 0x6f, 0x6e, 0x2d, 0x69, 0x6f, 0x2f, 0x67, 0x6f, - 0x2d, 0x61, 0x70, 0x70, 0x2d, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x77, 0x61, 0x6c, 0x6c, 0x2f, - 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -} - -var ( - file_models_proto_rawDescOnce sync.Once - file_models_proto_rawDescData = file_models_proto_rawDesc -) - -func file_models_proto_rawDescGZIP() []byte { - file_models_proto_rawDescOnce.Do(func() { - file_models_proto_rawDescData = protoimpl.X.CompressGZIP(file_models_proto_rawDescData) - }) - return file_models_proto_rawDescData -} - -var file_models_proto_msgTypes = make([]protoimpl.MessageInfo, 11) -var file_models_proto_goTypes = []interface{}{ - (*Ticker)(nil), // 0: models.Ticker - (*Agg)(nil), // 1: models.Agg - (*PriceUpdate)(nil), // 2: models.PriceUpdate - (*Announcement)(nil), // 3: models.Announcement - (*Screen)(nil), // 4: models.Screen - (*ScreenCluster)(nil), // 5: models.ScreenCluster - (*PresentationSettings)(nil), // 6: models.PresentationSettings - (*Update)(nil), // 7: models.Update - (*RGBA)(nil), // 8: models.RGBA - (*Tickers)(nil), // 9: models.Tickers - (*Empty)(nil), // 10: models.Empty -} -var file_models_proto_depIdxs = []int32{ - 1, // 0: models.Ticker.Aggs:type_name -> models.Agg - 6, // 1: models.ScreenCluster.Settings:type_name -> models.PresentationSettings - 4, // 2: models.ScreenCluster.Screens:type_name -> models.Screen - 8, // 3: models.PresentationSettings.UpColor:type_name -> models.RGBA - 8, // 4: models.PresentationSettings.DownColor:type_name -> models.RGBA - 8, // 5: models.PresentationSettings.BGColor:type_name -> models.RGBA - 8, // 6: models.PresentationSettings.FontColor:type_name -> models.RGBA - 8, // 7: models.PresentationSettings.TickerBoxBGColor:type_name -> models.RGBA - 2, // 8: models.Update.PriceUpdate:type_name -> models.PriceUpdate - 3, // 9: models.Update.Announcement:type_name -> models.Announcement - 5, // 10: models.Update.ScreenCluster:type_name -> models.ScreenCluster - 0, // 11: models.Update.Ticker:type_name -> models.Ticker - 6, // 12: models.Update.PresentationSettings:type_name -> models.PresentationSettings - 0, // 13: models.Tickers.Tickers:type_name -> models.Ticker - 4, // 14: models.Leader.JoinCluster:input_type -> models.Screen - 10, // 15: models.Leader.GetTickers:input_type -> models.Empty - 6, // 16: models.Leader.UpdatePresentationSettings:input_type -> models.PresentationSettings - 3, // 17: models.Leader.Announce:input_type -> models.Announcement - 10, // 18: models.Leader.GetScreenCluster:input_type -> models.Empty - 4, // 19: models.Leader.UpdateScreen:input_type -> models.Screen - 7, // 20: models.Leader.JoinCluster:output_type -> models.Update - 9, // 21: models.Leader.GetTickers:output_type -> models.Tickers - 6, // 22: models.Leader.UpdatePresentationSettings:output_type -> models.PresentationSettings - 3, // 23: models.Leader.Announce:output_type -> models.Announcement - 5, // 24: models.Leader.GetScreenCluster:output_type -> models.ScreenCluster - 4, // 25: models.Leader.UpdateScreen:output_type -> models.Screen - 20, // [20:26] is the sub-list for method output_type - 14, // [14:20] is the sub-list for method input_type - 14, // [14:14] is the sub-list for extension type_name - 14, // [14:14] is the sub-list for extension extendee - 0, // [0:14] is the sub-list for field type_name -} - -func init() { file_models_proto_init() } -func file_models_proto_init() { - if File_models_proto != nil { - return - } - if !protoimpl.UnsafeEnabled { - file_models_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Ticker); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_models_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Agg); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_models_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PriceUpdate); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_models_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Announcement); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_models_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Screen); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_models_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ScreenCluster); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_models_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PresentationSettings); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_models_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Update); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_models_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RGBA); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_models_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Tickers); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_models_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Empty); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_models_proto_rawDesc, - NumEnums: 0, - NumMessages: 11, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_models_proto_goTypes, - DependencyIndexes: file_models_proto_depIdxs, - MessageInfos: file_models_proto_msgTypes, - }.Build() - File_models_proto = out.File - file_models_proto_rawDesc = nil - file_models_proto_goTypes = nil - file_models_proto_depIdxs = nil -} - -// Reference imports to suppress errors if they are not otherwise used. -var _ context.Context -var _ grpc.ClientConnInterface - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -const _ = grpc.SupportPackageIsVersion6 - -// LeaderClient is the client API for Leader service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream. -type LeaderClient interface { - // Join the screen cluster. Updates to the cluster will be streamed to clients. - JoinCluster(ctx context.Context, in *Screen, opts ...grpc.CallOption) (Leader_JoinClusterClient, error) - // Get our current list of tickers. - GetTickers(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Tickers, error) - // Update our presentation settings. - UpdatePresentationSettings(ctx context.Context, in *PresentationSettings, opts ...grpc.CallOption) (*PresentationSettings, error) - // Announce a new message - Announce(ctx context.Context, in *Announcement, opts ...grpc.CallOption) (*Announcement, error) - // Get our current screen cluster. - GetScreenCluster(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ScreenCluster, error) - // UpdateScreen allows a screen to update it's details after it's started and joined. - UpdateScreen(ctx context.Context, in *Screen, opts ...grpc.CallOption) (*Screen, error) -} - -type leaderClient struct { - cc grpc.ClientConnInterface -} - -func NewLeaderClient(cc grpc.ClientConnInterface) LeaderClient { - return &leaderClient{cc} -} - -func (c *leaderClient) JoinCluster(ctx context.Context, in *Screen, opts ...grpc.CallOption) (Leader_JoinClusterClient, error) { - stream, err := c.cc.NewStream(ctx, &_Leader_serviceDesc.Streams[0], "/models.Leader/JoinCluster", opts...) - if err != nil { - return nil, err - } - x := &leaderJoinClusterClient{stream} - if err := x.ClientStream.SendMsg(in); err != nil { - return nil, err - } - if err := x.ClientStream.CloseSend(); err != nil { - return nil, err - } - return x, nil -} - -type Leader_JoinClusterClient interface { - Recv() (*Update, error) - grpc.ClientStream -} - -type leaderJoinClusterClient struct { - grpc.ClientStream -} - -func (x *leaderJoinClusterClient) Recv() (*Update, error) { - m := new(Update) - if err := x.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} - -func (c *leaderClient) GetTickers(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Tickers, error) { - out := new(Tickers) - err := c.cc.Invoke(ctx, "/models.Leader/GetTickers", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *leaderClient) UpdatePresentationSettings(ctx context.Context, in *PresentationSettings, opts ...grpc.CallOption) (*PresentationSettings, error) { - out := new(PresentationSettings) - err := c.cc.Invoke(ctx, "/models.Leader/UpdatePresentationSettings", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *leaderClient) Announce(ctx context.Context, in *Announcement, opts ...grpc.CallOption) (*Announcement, error) { - out := new(Announcement) - err := c.cc.Invoke(ctx, "/models.Leader/Announce", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *leaderClient) GetScreenCluster(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ScreenCluster, error) { - out := new(ScreenCluster) - err := c.cc.Invoke(ctx, "/models.Leader/GetScreenCluster", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *leaderClient) UpdateScreen(ctx context.Context, in *Screen, opts ...grpc.CallOption) (*Screen, error) { - out := new(Screen) - err := c.cc.Invoke(ctx, "/models.Leader/UpdateScreen", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -// LeaderServer is the server API for Leader service. -type LeaderServer interface { - // Join the screen cluster. Updates to the cluster will be streamed to clients. - JoinCluster(*Screen, Leader_JoinClusterServer) error - // Get our current list of tickers. - GetTickers(context.Context, *Empty) (*Tickers, error) - // Update our presentation settings. - UpdatePresentationSettings(context.Context, *PresentationSettings) (*PresentationSettings, error) - // Announce a new message - Announce(context.Context, *Announcement) (*Announcement, error) - // Get our current screen cluster. - GetScreenCluster(context.Context, *Empty) (*ScreenCluster, error) - // UpdateScreen allows a screen to update it's details after it's started and joined. - UpdateScreen(context.Context, *Screen) (*Screen, error) -} - -// UnimplementedLeaderServer can be embedded to have forward compatible implementations. -type UnimplementedLeaderServer struct { -} - -func (*UnimplementedLeaderServer) JoinCluster(*Screen, Leader_JoinClusterServer) error { - return status.Errorf(codes.Unimplemented, "method JoinCluster not implemented") -} -func (*UnimplementedLeaderServer) GetTickers(context.Context, *Empty) (*Tickers, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetTickers not implemented") -} -func (*UnimplementedLeaderServer) UpdatePresentationSettings(context.Context, *PresentationSettings) (*PresentationSettings, error) { - return nil, status.Errorf(codes.Unimplemented, "method UpdatePresentationSettings not implemented") -} -func (*UnimplementedLeaderServer) Announce(context.Context, *Announcement) (*Announcement, error) { - return nil, status.Errorf(codes.Unimplemented, "method Announce not implemented") -} -func (*UnimplementedLeaderServer) GetScreenCluster(context.Context, *Empty) (*ScreenCluster, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetScreenCluster not implemented") -} -func (*UnimplementedLeaderServer) UpdateScreen(context.Context, *Screen) (*Screen, error) { - return nil, status.Errorf(codes.Unimplemented, "method UpdateScreen not implemented") -} - -func RegisterLeaderServer(s *grpc.Server, srv LeaderServer) { - s.RegisterService(&_Leader_serviceDesc, srv) -} - -func _Leader_JoinCluster_Handler(srv interface{}, stream grpc.ServerStream) error { - m := new(Screen) - if err := stream.RecvMsg(m); err != nil { - return err - } - return srv.(LeaderServer).JoinCluster(m, &leaderJoinClusterServer{stream}) -} - -type Leader_JoinClusterServer interface { - Send(*Update) error - grpc.ServerStream -} - -type leaderJoinClusterServer struct { - grpc.ServerStream -} - -func (x *leaderJoinClusterServer) Send(m *Update) error { - return x.ServerStream.SendMsg(m) -} - -func _Leader_GetTickers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(Empty) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(LeaderServer).GetTickers(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/models.Leader/GetTickers", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(LeaderServer).GetTickers(ctx, req.(*Empty)) - } - return interceptor(ctx, in, info, handler) -} - -func _Leader_UpdatePresentationSettings_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(PresentationSettings) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(LeaderServer).UpdatePresentationSettings(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/models.Leader/UpdatePresentationSettings", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(LeaderServer).UpdatePresentationSettings(ctx, req.(*PresentationSettings)) - } - return interceptor(ctx, in, info, handler) -} - -func _Leader_Announce_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(Announcement) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(LeaderServer).Announce(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/models.Leader/Announce", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(LeaderServer).Announce(ctx, req.(*Announcement)) - } - return interceptor(ctx, in, info, handler) -} - -func _Leader_GetScreenCluster_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(Empty) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(LeaderServer).GetScreenCluster(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/models.Leader/GetScreenCluster", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(LeaderServer).GetScreenCluster(ctx, req.(*Empty)) - } - return interceptor(ctx, in, info, handler) -} - -func _Leader_UpdateScreen_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(Screen) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(LeaderServer).UpdateScreen(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/models.Leader/UpdateScreen", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(LeaderServer).UpdateScreen(ctx, req.(*Screen)) - } - return interceptor(ctx, in, info, handler) -} - -var _Leader_serviceDesc = grpc.ServiceDesc{ - ServiceName: "models.Leader", - HandlerType: (*LeaderServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "GetTickers", - Handler: _Leader_GetTickers_Handler, - }, - { - MethodName: "UpdatePresentationSettings", - Handler: _Leader_UpdatePresentationSettings_Handler, - }, - { - MethodName: "Announce", - Handler: _Leader_Announce_Handler, - }, - { - MethodName: "GetScreenCluster", - Handler: _Leader_GetScreenCluster_Handler, - }, - { - MethodName: "UpdateScreen", - Handler: _Leader_UpdateScreen_Handler, - }, - }, - Streams: []grpc.StreamDesc{ - { - StreamName: "JoinCluster", - Handler: _Leader_JoinCluster_Handler, - ServerStreams: true, - }, - }, - Metadata: "models.proto", -} diff --git a/models/models.proto b/models/models.proto deleted file mode 100644 index 73a4182..0000000 --- a/models/models.proto +++ /dev/null @@ -1,115 +0,0 @@ -syntax = "proto3"; - -package models; -option go_package = "github.com/massive-com/go-app-ticker-wall/v2/models"; - -// Leader is the exposed endpoint(s) for the leader service. -service Leader { - // Join the screen cluster. Updates to the cluster will be streamed to clients. - rpc JoinCluster(Screen) returns (stream Update) {} - - // Get our current list of tickers. - rpc GetTickers(Empty) returns (Tickers) {} - - // Update our presentation settings. - rpc UpdatePresentationSettings(PresentationSettings) returns (PresentationSettings) {} - - // Announce a new message - rpc Announce(Announcement) returns (Announcement) {} - - // Get our current screen cluster. - rpc GetScreenCluster(Empty) returns (ScreenCluster) {} - - // UpdateScreen allows a screen to update it's details after it's started and joined. - rpc UpdateScreen(Screen) returns (Screen) {} -} - -// Ticker is used to update a tickers information ( leader -> follower ). -message Ticker { - string Ticker = 1; - string CompanyName = 2; - int64 OutstandingShares = 3; - double Price = 4; - double MarketCap = 5; - double PriceChangePercentage = 6; - double PreviousClosePrice = 7; - int32 Index = 8; - int32 Img = 9; - bytes ImgData = 10; - repeated Agg Aggs = 11; -} - -// Agg is an individual aggregate used to generate graphs. -message Agg { - double Price = 1; - int32 Volume = 2; - int64 Timestamp = 3; -} - -// PriceUpdate is the message sent when a price updates for a ticker. -message PriceUpdate { - string Ticker = 1; - double Price = 2; -} - -// Announcement is used to display a special message on the display. -message Announcement { - string Message = 1; - int32 AnnouncementType = 2; - int64 ShowAtTimestampMS = 3; - int64 LifespanMS = 4; - int32 Animation = 5; -} - -// Screen contains all screen information about an individual screen. -message Screen { - string UUID = 1; - int32 Width = 2; - int32 Height = 3; - int32 Index = 4; -} - -// ScreenCluster contains information about the whole screen cluster. -message ScreenCluster { - PresentationSettings Settings = 1; - repeated Screen Screens = 2; -} - -message PresentationSettings { - int32 TickerBoxWidth = 1; - int32 ScrollSpeed = 2; - RGBA UpColor = 3; - RGBA DownColor = 4; - RGBA BGColor = 5; - RGBA FontColor = 6; - RGBA TickerBoxBGColor = 7; - bool ShowLogos = 8; - bool ShowFPS = 9; - int32 AnimationDurationMS = 10; - bool PerTickUpdates = 11; -} - -// Update encapsulates different update messages. -message Update { - int32 UpdateType = 1; - PriceUpdate PriceUpdate = 2; - Announcement Announcement = 3; - ScreenCluster ScreenCluster = 4; - Ticker Ticker = 5; - PresentationSettings PresentationSettings = 6; -} - -// RGBA is how we represent colors. -message RGBA { - int32 Red = 1; - int32 Green = 2; - int32 Blue = 3; - int32 Alpha = 4; -} - - -// Group of Tickers -message Tickers { - repeated Ticker Tickers = 1; -} -message Empty {} // service has no input diff --git a/models/rgba.go b/models/rgba.go deleted file mode 100644 index 93321cf..0000000 --- a/models/rgba.go +++ /dev/null @@ -1,12 +0,0 @@ -package models - -import "github.com/massive-com/nanovgo" - -func (g *RGBA) ToNanov() nanovgo.Color { - return nanovgo.RGBA( - uint8(g.Red), - uint8(g.Green), - uint8(g.Blue), - uint8(g.Alpha), - ) -} diff --git a/models/screen-cluster.go b/models/screen-cluster.go deleted file mode 100644 index d9d99f8..0000000 --- a/models/screen-cluster.go +++ /dev/null @@ -1,39 +0,0 @@ -package models - -// This has helper functions for ScreenCluster model. - -// ScreenSlice is sortable by index. -type ScreenSlice []*Screen - -func (a ScreenSlice) Len() int { return len(a) } -func (a ScreenSlice) Swap(i, j int) { a[i], a[j] = a[j], a[i] } -func (a ScreenSlice) Less(i, j int) bool { return a[i].Index < a[j].Index } - -// ScreenGlobalOffset gets the global offset for a given screen UUID in the cluster. -func (s *ScreenCluster) ScreenGlobalOffset(screenUUID string) float32 { - var offset float32 - for _, scr := range s.Screens { - // This is our screen, do not add our own width. - if scr.UUID == screenUUID { - break - } - - // Otherwise add this screens offset to the global offset. - offset += float32(scr.Width) - } - return offset -} - -// NumberOfScreens returns the total number of screen devices in the cluster. -func (s *ScreenCluster) NumberOfScreens() int { - return len(s.Screens) -} - -// GlobalViewportSize gets the entire pixel width of the cluster. -func (s *ScreenCluster) GlobalViewportSize() int { - globalViewportSize := 0 - for _, scr := range s.Screens { - globalViewportSize += int(scr.Width) - } - return globalViewportSize -} diff --git a/models/tickers.go b/models/tickers.go deleted file mode 100644 index 5b06d8d..0000000 --- a/models/tickers.go +++ /dev/null @@ -1,8 +0,0 @@ -package models - -// TickerSlice is sortable by Ticker. -type TickerSlice []*Ticker - -func (a TickerSlice) Len() int { return len(a) } -func (a TickerSlice) Swap(i, j int) { a[i], a[j] = a[j], a[i] } -func (a TickerSlice) Less(i, j int) bool { return a[i].Ticker < a[j].Ticker } diff --git a/server/grpc.go b/server/grpc.go deleted file mode 100644 index 6574f5c..0000000 --- a/server/grpc.go +++ /dev/null @@ -1,30 +0,0 @@ -package server - -import ( - "context" - "fmt" - "net" - - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/sirupsen/logrus" - "google.golang.org/grpc" -) - -// startGRPC starts the gRPC server. When the given context ends, it will shutdown the gRPC server. -func startGRPC(ctx context.Context, port int, tickerWallLeader models.LeaderServer) error { - lis, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", port)) - if err != nil { - return fmt.Errorf("failed to listen: %w", err) - } - - var opts []grpc.ServerOption - grpcServer := grpc.NewServer(opts...) - go func() { - <-ctx.Done() - logrus.Debug("Closing gRPC server.", ctx.Err()) - grpcServer.Stop() - }() - - models.RegisterLeaderServer(grpcServer, tickerWallLeader) - return grpcServer.Serve(lis) -} diff --git a/server/http.go b/server/http.go deleted file mode 100644 index 7b9af29..0000000 --- a/server/http.go +++ /dev/null @@ -1,111 +0,0 @@ -package server - -import ( - "context" - "fmt" - "net/http" - "time" - - "github.com/gin-gonic/gin" - "github.com/imdario/mergo" - "github.com/massive-com/go-app-ticker-wall/v2/leader" - "github.com/massive-com/go-app-ticker-wall/v2/models" - "github.com/sirupsen/logrus" -) - -func runHTTPServer(ctx context.Context, port int, leaderObj *leader.Leader) error { - r := gin.Default() - r.GET("/ping", func(c *gin.Context) { - c.JSON(200, gin.H{ - "message": "pong", - }) - }) - - // Register routes. - r.GET("/v1/cluster", getCluster(leaderObj)) - r.POST("/v1/presentation", updatePresentation(leaderObj)) - r.POST("/v1/announcement", createAnnouncement(leaderObj)) - - srv := &http.Server{ - Addr: fmt.Sprintf(":%d", port), - Handler: r, - } - - // Gracefully shutdown the HTTP server when context is closed. - go func() { - <-ctx.Done() - if err := srv.Shutdown(ctx); err != nil { - logrus.WithError(err).Error("Could not shutdown http server.") - } - }() - - logrus.Info("HTTP Server Listening on: ", port) - return srv.ListenAndServe() -} - -func createAnnouncement(leaderObj *leader.Leader) func(*gin.Context) { - return func(c *gin.Context) { - var announcement *models.Announcement - if err := c.ShouldBindJSON(&announcement); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Set the display time of the announcement to +100 ms from now. - startTimer := time.Now().Add(100 * time.Millisecond) - announcement.ShowAtTimestampMS = startTimer.UnixNano() / int64(time.Millisecond) - - // Tell all screen clients to update. - leaderObj.Updates <- &models.Update{ - UpdateType: int32(models.UpdateTypeAnnouncement), - Announcement: announcement, - } - - c.JSON(200, gin.H{ - "done": true, - "results": announcement, - }) - } -} - -func updatePresentation(leaderObj *leader.Leader) func(*gin.Context) { - return func(c *gin.Context) { - // Parse incoming settings. - var presentationSettings *models.PresentationSettings - if err := c.ShouldBindJSON(&presentationSettings); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - logrus.Info("Presentation Settings: ", presentationSettings) - - leaderObj.Lock() - - // Merge the new settings into the current settings. This make is so that updating a presentation setting - // doesn't require all settings, you can just update 1 attribute. - if err := mergo.MergeWithOverwrite(leaderObj.PresentationSettings, presentationSettings); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - leaderObj.Unlock() - - // Update the cluster - leaderObj.Updates <- &models.Update{ - UpdateType: int32(models.UpdateTypeCluster), - ScreenCluster: leaderObj.CurrentScreenCluster(), - } - - c.JSON(200, gin.H{ - "done": true, - }) - } -} - -func getCluster(leaderObj *leader.Leader) func(*gin.Context) { - return func(c *gin.Context) { - c.JSON(200, gin.H{ - "cluster": leaderObj.CurrentScreenCluster(), - }) - } -} diff --git a/server/server.go b/server/server.go deleted file mode 100644 index 469af19..0000000 --- a/server/server.go +++ /dev/null @@ -1,59 +0,0 @@ -package server - -import ( - "context" - "fmt" - "os" - "os/signal" - "syscall" - - leader "github.com/massive-com/go-app-ticker-wall/v2/leader" - tombv2 "gopkg.in/tomb.v2" -) - -type ServiceConfig struct { - Debug bool - GRPCPort int - HTTPPort int - LeaderConfig leader.Config -} - -func Run(cfg *ServiceConfig) error { - // Global top level context. - tomb, ctx := tombv2.WithContext(context.Background()) - - // Start the ticker wall leader. - clusterLeader, err := leader.New(&cfg.LeaderConfig) - if err != nil { - return fmt.Errorf("could not create cluster leader: %w", err) - } - - tomb.Go(func() error { - return clusterLeader.Run(ctx) - }) - - // Start the GRPC server. - tomb.Go(func() error { - return startGRPC(ctx, cfg.GRPCPort, clusterLeader) - }) - - // Start the HTTP admin server. - tomb.Go(func() error { - return runHTTPServer(ctx, cfg.HTTPPort, clusterLeader) - }) - - // Wait for OS signals: - sigs := make(chan os.Signal) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - tomb.Go(func() error { - select { - case <-sigs: - tomb.Kill(nil) - case <-tomb.Dying(): - // Exit. - } - return nil - }) - - return tomb.Wait() -}