diff --git a/.github/macmon.png b/.github/macmon.png new file mode 100644 index 0000000..ea22f92 Binary files /dev/null and b/.github/macmon.png differ diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..482707a --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,25 @@ +name: check + +on: + push: + branches: + - '**' + +jobs: + check: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/cache@v4 + with: + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + + - run: rustup update --no-self-update stable && rustup default stable + - run: cargo fmt --check + - run: cargo build --release --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3c7b779 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: release + +on: + push: + tags: 'v*' + +permissions: + contents: write + +jobs: + build: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/cache@v4 + with: + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + + - run: rustup update --no-self-update stable && rustup default stable + - run: cargo fmt --check + - run: cargo build --release --locked + - name: archiving + id: archive + run: | + strip target/release/macmon + cp target/release/macmon macmon + tar czf macmon-${{ github.ref_name }}.tar.gz readme.md LICENSE macmon + ls -lah | grep macmon + + - uses: softprops/action-gh-release@v2 + with: + files: macmon-${{ github.ref_name }}.tar.gz + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: mislav/bump-homebrew-formula-action@v3 + with: + homebrew-tap: vladkens/homebrew-apps + formula-name: macmon + formula-path: macmon.rb + commit-message: "{{formulaName}} {{version}}" + download-url: https://github.com/vladkens/macmon/releases/download/${{ github.ref_name }}/macmon-${{ github.ref_name }}.tar.gz + env: + COMMITTER_TOKEN: ${{ secrets.HOMEBREW_REPO_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8f63cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +/target diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..45b28b5 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,5 @@ +# https://rust-lang.github.io/rustfmt/?version=v1.6.0&search= +edition = "2021" +max_width = 100 +use_small_heuristics = "Max" +tab_spaces = 2 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3fbb61e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer", + "editor.formatOnSave": true, + }, + "terminal.integrated.tabStopWidth": 2, + "editor.formatOnSave": true, + "code-runner.executorMap": { + "rust": "cargo run -r #$fileName", + }, + "files.exclude": { "Cargo.lock": true }, +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2b5748f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,750 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + +[[package]] +name = "anstream" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" + +[[package]] +name = "anstyle-parse" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "bitflags" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db83dced34638ad474f39f250d7fea9598bdd239eaced1bdf45d597da0f433f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7e204572485eb3fbf28f871612191521df159bc3e15a9f5064c66dba3a8c05f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c780290ccf4fb26629baa7a1081e68ced113f1d3ec302fa5948f1c381ebf06c6" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" + +[[package]] +name = "colorchoice" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" + +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "either" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "libc" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" + +[[package]] +name = "lru" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "macmon" +version = "0.1.0" +dependencies = [ + "clap", + "core-foundation", + "crossterm", + "libc", + "ratatui", + "serde_json", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.5", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "itertools", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustversion" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "stability" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ff9eaf853dec4c8802325d8b6d3dffa86cc707fd7a1a4cdbf416e13b061787a" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-segmentation" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" + +[[package]] +name = "unicode-truncate" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5fbabedabe362c618c714dbefda9927b5afc8e2a8102f47f081089a9019226" +dependencies = [ + "itertools", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.5", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm 0.52.5", + "windows_aarch64_msvc 0.52.5", + "windows_i686_gnu 0.52.5", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.5", + "windows_x86_64_gnu 0.52.5", + "windows_x86_64_gnullvm 0.52.5", + "windows_x86_64_msvc 0.52.5", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" + +[[package]] +name = "zerocopy" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae87e3fcd617500e5d106f0380cf7b77f3c6092aae37191433159dda23cfb087" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6713af5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "macmon" +version = "0.1.0" +edition = "2021" + +[lints.rust] +non_camel_case_types = "allow" +unused_assignments = "allow" + +[profile.release] +panic = "abort" + +[dependencies] +clap = {version = "4.5.7", features = ["derive"]} +core-foundation = "0.9.4" +crossterm = "0.27.0" +libc = "0.2.155" +ratatui = "0.26.3" +serde_json = "1.0.117" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4659b63 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 vladkens + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..4cdabea --- /dev/null +++ b/readme.md @@ -0,0 +1,78 @@ +# macmon + +
+ macmon preview +
+ Sudoless performance monitoring CLI tool for Apple Silicon processors. +
+ +## Motivation + +Apple Silicon processors don't provide an easy way to see live power consumption. I was interested in this information while testing local LLM models. `asitop` is a nice and simple TUI to quickly see current metrics, but it reads data from `powermetrics` and requires root privileges. `macmon` uses a private macOS API to gather metrics (essentially the same as `powermetrics`) but runs without sudo. 🎉 + +## 🌟 Features + +- 🚫 Works without sudo +- ⚡ Real-time CPU / GPU / ANE power usage +- 📊 CPU utilization per cluster +- 💾 RAM / Swap usage +- 📈 Historical charts + avg / max values +- 🪟 Can be rendered in a small window +- 🦀 Written in Rust + +## 🍺 Install via Homebrew + +```sh +brew install vladkens/tap/macmon +``` + +## 📦 Install from source + +1. Install [Rust toolchain](https://www.rust-lang.org/tools/install) + +2. Clone the repo: + +```sh +git clone https://github.com/vladkens/macmon.git && cd macmon +``` + +3. Build and run: + +```sh +cargo run -r +``` + +4. (Optionally) Binary can be moved to bin folder: + +```sh +sudo cp target/release/macmon /usr/local/bin +``` + +## 🚀 Usage + +```sh +Usage: macmon [OPTIONS] + +Options: + -i, --interval Update interval in milliseconds [default: 1000] + --raw Print raw data instead of TUI + -h, --help Print help + -V, --version Print version +``` + +## 🤝 Contributing +We love contributions! Whether you have ideas, suggestions, or bug reports, feel free to open an issue or submit a pull request. Your input is essential in helping us improve `macmon` 💪 + +## 📝 License +`macmon` is distributed under the MIT License. For more details, check out the LICENSE. + +## 🔍 See also +- [tlkh/asitop](https://github.com/tlkh/asitop) – Original tool. Python, requires sudo. +- [dehydratedpotato/socpowerbud](https://github.com/dehydratedpotato/socpowerbud) – ObjectiveC, sudoless, no TUI. +- [op06072/NeoAsitop](https://github.com/op06072/NeoAsitop) – Swift, sudoless. +- [graelo/pumas](https://github.com/graelo/pumas) – Rust, requires sudo. +- [context-labs/mactop](https://github.com/context-labs/mactop) – Go, requires sudo. + +--- + +*PS: One More Thing... Remember, monitoring your Mac's performance with `macmon` is like having a personal trainer for your processor — keeping those cores in shape! 💪* diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..3122819 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,332 @@ +use crate::metrics::{self, MemoryUsage, PerfSample, SocInfo}; +use crossterm::{ + event::{self, KeyCode, KeyModifiers}, + terminal, ExecutableCommand, +}; +use ratatui::{prelude::*, widgets::*}; +use std::{error::Error, io::stdout, time::Instant}; +use std::{sync::mpsc, time::Duration}; + +const GB: u64 = 1024 * 1024 * 1024; +const MAX_SPARKLINE: usize = 128; +const BASE_COLOR: Color = Color::LightGreen; + +fn enter_term() -> Terminal { + std::panic::set_hook(Box::new(|info| { + leave_term(); + eprintln!("{}", info); + })); + + terminal::enable_raw_mode().unwrap(); + stdout().execute(terminal::EnterAlternateScreen).unwrap(); + + let term = CrosstermBackend::new(std::io::stdout()); + let term = Terminal::new(term).unwrap(); + term +} + +fn leave_term() { + terminal::disable_raw_mode().unwrap(); + stdout().execute(terminal::LeaveAlternateScreen).unwrap(); +} + +// Metrics + +fn items_add(vec: &mut Vec, val: u64) -> &Vec { + vec.insert(0, val); + if vec.len() > MAX_SPARKLINE { + vec.pop(); + } + vec +} + +#[derive(Debug, Default)] +struct FreqStore { + items: Vec, + top_value: u64, + usage: u8, +} + +impl FreqStore { + fn push(&mut self, value: u64, usage: u8) { + // items_add(&mut self.items, value); + items_add(&mut self.items, usage as u64); + self.top_value = value; + self.usage = usage; + } +} + +#[derive(Debug, Default)] +struct PowerStore { + items: Vec, + top_value: f64, + max_value: f64, + avg_value: f64, +} + +impl PowerStore { + fn push(&mut self, value: f64) { + items_add(&mut self.items, (value * 1000.0) as u64); + self.top_value = value; + self.avg_value = self.items.iter().sum::() as f64 / self.items.len() as f64 / 1000.0; + self.max_value = self.items.iter().max().map_or(0, |v| *v) as f64 / 1000.0; + } +} + +#[derive(Debug, Default)] +struct MemoryStore { + items: Vec, + ram_usage: u64, + ram_total: u64, + swap_usage: u64, + swap_total: u64, + max_ram: u64, +} + +impl MemoryStore { + fn push(&mut self, value: MemoryUsage) { + items_add(&mut self.items, value.ram_usage); + self.ram_usage = value.ram_usage; + self.ram_total = value.ram_total; + self.swap_usage = value.swap_usage; + self.swap_total = value.swap_total; + self.max_ram = self.items.iter().max().map_or(0, |v| *v); + } +} + +// Rendering + +fn h_stack(area: Rect) -> (Rect, Rect) { + let ha = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Fill(1), Constraint::Fill(1)].as_ref()) + .split(area); + + (ha[0], ha[1]) +} + +fn title_block<'a>(label_l: &str, label_r: &str) -> Block<'a> { + let mut block = Block::new() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(BASE_COLOR) + // .title_style(Style::default().gray()) + .padding(Padding::horizontal(0)); + + if label_l.len() > 0 { + block = block.title(block::Title::from(format!(" {label_l} ")).alignment(Alignment::Left)); + } + + if label_r.len() > 0 { + block = block.title(block::Title::from(format!(" {label_r} ")).alignment(Alignment::Right)); + } + + block +} + +fn get_freq_block<'a>(label: &str, val: &'a FreqStore) -> Sparkline<'a> { + let label = format!("{} {:3}% @ {:4.0} MHz", label, val.usage, val.top_value); + Sparkline::default() + .block(title_block(label.as_str(), "")) + .direction(RenderDirection::RightToLeft) + .data(&val.items) + .max(100) + .style(BASE_COLOR) +} + +fn get_power_block<'a>(label: &str, val: &'a PowerStore) -> Sparkline<'a> { + let label = format!( + // "{} {:.2}W (avg: {:.2}W, max: {:.2}W)", + "{} {:.2}W (~{:.2}W ^{:.2}W)", + // "{} {:.2}W (~{:.2}W ↑{:.2}W)", + label, + val.top_value, + val.avg_value, + val.max_value + ); + + Sparkline::default() + .block(title_block(label.as_str(), "")) + .direction(RenderDirection::RightToLeft) + .data(&val.items) + .style(BASE_COLOR) +} + +fn get_ram_block<'a>(val: &'a MemoryStore) -> Sparkline<'a> { + let ram_usage_gb = val.ram_usage as f64 / GB as f64; + let ram_total_gb = val.ram_total as f64 / GB as f64; + + let swap_usage_gb = val.swap_usage as f64 / GB as f64; + let swap_total_gb = val.swap_total as f64 / GB as f64; + + let label_l = format!("RAM {:4.2} / {:4.1} GB", ram_usage_gb, ram_total_gb); + let label_r = format!("SWAP {:.2} / {:.1} GB", swap_usage_gb, swap_total_gb); + + Sparkline::default() + .block(title_block(label_l.as_str(), label_r.as_str())) + .direction(RenderDirection::RightToLeft) + .data(&val.items) + .max(val.ram_total) + .style(BASE_COLOR) +} + +// App + +enum Event { + Update(PerfSample), + Quit, + Tick, +} + +fn run_input_loop(tx: mpsc::Sender, tick: u64) { + let tick_rate = Duration::from_millis(tick); + + std::thread::spawn(move || { + let mut last_tick = Instant::now(); + + loop { + if event::poll(Duration::from_millis(100)).unwrap() { + match event::read().unwrap() { + event::Event::Key(key) => { + if key.code == KeyCode::Char('q') + || (key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL) + { + tx.send(Event::Quit).unwrap(); + } + } + _ => {} + }; + } + if last_tick.elapsed() >= tick_rate { + tx.send(Event::Tick).unwrap(); + last_tick = Instant::now(); + } + } + }); +} + +fn run_perfs_loop(tx: mpsc::Sender, info: SocInfo, cycle_time: u64) { + let interval = cycle_time.max(100); + let check_ts = 100; + + std::thread::spawn(move || { + let mut sub = metrics::SubsChan::new(info).unwrap(); + + loop { + let data = sub.sample(check_ts).unwrap(); + tx.send(Event::Update(data)).unwrap(); + std::thread::sleep(Duration::from_millis(interval - check_ts)); + } + }); +} + +#[derive(Debug, Default)] +pub struct App { + info: metrics::SocInfo, + ecpu_freq: FreqStore, + pcpu_freq: FreqStore, + gpu_freq: FreqStore, + cpu_power: PowerStore, + gpu_power: PowerStore, + ane_power: PowerStore, + all_power: PowerStore, + memory: MemoryStore, +} + +impl App { + pub fn new(info: metrics::SocInfo) -> Self { + let mut app = App::default(); + app.info = info; + app + } + + fn update_metrics(&mut self, data: PerfSample) { + self.cpu_power.push(data.cpu_power as f64); + self.gpu_power.push(data.gpu_power as f64); + self.ane_power.push(data.ane_power as f64); + self.all_power.push(data.all_watts as f64); + self.ecpu_freq.push(data.ecpu_usage.0 as u64, (data.ecpu_usage.1 * 100.0) as u8); + self.pcpu_freq.push(data.pcpu_usage.0 as u64, (data.pcpu_usage.1 * 100.0) as u8); + self.gpu_freq.push(data.gpu_usage.0 as u64, (data.gpu_usage.1 * 100.0) as u8); + self.memory.push(data.memory); + } + + fn render(&self, f: &mut Frame) { + let label_l = format!( + "{} ({}E+{}P+{}GPU {}GB)", + self.info.chip_name, + self.info.ecpu_cores, + self.info.pcpu_cores, + self.info.gpu_cores, + self.info.memory_gb, + ); + + let label_r = format!( + "Power: {:.2}W (avg: {:.2}W, max: {:.2}W)", + self.all_power.top_value, self.all_power.avg_value, self.all_power.max_value + ); + + // let p = LineGauge::default().label(format!("─ {label_l}")); + // f.render_widget(p, f.size()); + // return; + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Fill(2), Constraint::Fill(1)].as_ref()) + .split(f.size()); + + let block = title_block(&label_l, ""); + let iarea = block.inner(rows[0]); + f.render_widget(block, rows[0]); + + let iarea = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Fill(1), Constraint::Fill(1)].as_ref()) + .split(iarea); + + // 1st row + let (c1, c2) = h_stack(iarea[0]); + f.render_widget(get_freq_block("E-CPU", &self.ecpu_freq), c1); + f.render_widget(get_freq_block("P-CPU", &self.pcpu_freq), c2); + + // 2nd row + let (c1, c2) = h_stack(iarea[1]); + f.render_widget(get_ram_block(&self.memory), c1); + f.render_widget(get_freq_block("GPU", &self.gpu_freq), c2); + + // 3rd row + let block = title_block(&label_r, ""); + let iarea = block.inner(rows[1]); + f.render_widget(block, rows[1]); + + let ha = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Fill(1), Constraint::Fill(1), Constraint::Fill(1)].as_ref()) + .split(iarea); + + f.render_widget(get_power_block("CPU", &self.cpu_power), ha[0]); + f.render_widget(get_power_block("GPU", &self.gpu_power), ha[1]); + f.render_widget(get_power_block("ANE", &self.ane_power), ha[2]); + } + + pub fn run_loop(&mut self, interval: u64) -> Result<(), Box> { + let (tx, rx) = mpsc::channel::(); + run_input_loop(tx.clone(), 200); + run_perfs_loop(tx.clone(), self.info.clone(), interval); + + let mut term = enter_term(); + + loop { + term.draw(|f| self.render(f)).unwrap(); + + match rx.recv()? { + Event::Update(data) => self.update_metrics(data), + Event::Quit => break, + _ => {} + } + } + + leave_term(); + Ok(()) + } +} diff --git a/src/cfutil.rs b/src/cfutil.rs new file mode 100644 index 0000000..2453793 --- /dev/null +++ b/src/cfutil.rs @@ -0,0 +1,124 @@ +use core_foundation::{ + base::{kCFAllocatorDefault, kCFAllocatorNull, CFRelease, CFTypeRef}, + dictionary::{ + CFDictionaryGetCount, CFDictionaryGetKeysAndValues, CFDictionaryGetValue, CFDictionaryRef, + }, + string::{kCFStringEncodingUTF8, CFStringCreateWithBytesNoCopy, CFStringGetCString, CFStringRef}, +}; + +pub type CVoidRef = *const std::ffi::c_void; + +// pub fn json_save(filepath: &str, val: &Value) { +// use std::fs::File; +// use std::io::prelude::*; +// let val = serde_json::to_string_pretty(&val).unwrap(); +// let mut file = File::create(filepath).unwrap(); +// file.write_all(val.as_bytes()).unwrap(); +// } + +// pub fn cf_to_json(val: CFTypeRef) -> Value { +// unsafe { +// let tid = CFGetTypeID(val); +// match tid { +// _ if tid == CFStringGetTypeID() => { +// json!(CFString::wrap_under_get_rule(val as CFStringRef).to_string()) +// } +// _ if tid == CFNumberGetTypeID() => { +// json!(CFNumber::wrap_under_get_rule(val as CFNumberRef).to_i64()) +// } +// _ if tid == CFBooleanGetTypeID() => { +// json!(CFBooleanGetValue(val as CFBooleanRef)) +// } +// _ if tid == CFDictionaryGetTypeID() => { +// let val = CFDictionary::::wrap_under_get_rule(val as CFDictionaryRef); +// let (keys, vals) = val.get_keys_and_values(); + +// let mut map: HashMap = HashMap::new(); +// for (key, value) in keys.iter().zip(vals.iter()) { +// map.insert( +// CFString::wrap_under_get_rule(*key as CFStringRef).to_string(), +// cf_to_json(*value), +// ); +// } +// json!(map) +// } +// _ if tid == CFArrayGetTypeID() => { +// let val = CFArray::::wrap_under_get_rule(val as CFArrayRef); +// let mut arr: Vec = Vec::new(); +// for x in val.iter() { +// arr.push(cf_to_json(*x)); +// } +// json!(arr) +// } +// _ if tid == CFDataGetTypeID() => { +// let val = CFData::wrap_under_get_rule(val as CFDataRef); +// json!(val.bytes().to_vec()) +// } +// _ => { +// eprintln!("UNKNOWN_TYPE: {:?}", tid); +// CFShow(val); +// json!(null) +// } +// } +// } +// } + +pub fn cfstr(val: &str) -> CFStringRef { + // this creates broken objects if string len > 9 + // CFString::from_static_string(val).as_concrete_TypeRef() + // CFString::new(val).as_concrete_TypeRef() + + unsafe { + CFStringCreateWithBytesNoCopy( + kCFAllocatorDefault, + val.as_ptr(), + val.len() as isize, + kCFStringEncodingUTF8, + 0, + kCFAllocatorNull, + ) + } +} + +pub fn from_cfstr(val: CFStringRef) -> String { + unsafe { + let mut buf = Vec::with_capacity(128); + if CFStringGetCString(val, buf.as_mut_ptr(), 128, kCFStringEncodingUTF8) == 0 { + panic!("Failed to convert CFString to CString"); + } + std::ffi::CStr::from_ptr(buf.as_ptr()).to_string_lossy().to_string() + } +} + +pub fn cfdict_keys(dict: CFDictionaryRef) -> Vec { + unsafe { + let count = CFDictionaryGetCount(dict) as usize; + let mut keys: Vec = Vec::with_capacity(count); + let mut vals: Vec = Vec::with_capacity(count); + CFDictionaryGetKeysAndValues(dict, keys.as_mut_ptr() as _, vals.as_mut_ptr()); + keys.set_len(count); + vals.set_len(count); + + keys.iter().map(|k| from_cfstr(*k as _)).collect() + } +} + +pub fn cfdict_get_val(dict: CFDictionaryRef, key: &str) -> Option { + unsafe { + let key = cfstr(key); + let val = CFDictionaryGetValue(dict, key as _); + CFRelease(key as _); + + match val { + _ if val.is_null() => None, + _ => Some(val), + } + } +} + +pub fn cfdict_get_str(dict: CFDictionaryRef, key: &str) -> Option { + match cfdict_get_val(dict, key) { + Some(val) => Some(from_cfstr(val as _)), + None => None, + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c2ef258 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,38 @@ +pub mod app; +pub mod cfutil; +pub mod metrics; + +use clap::Parser; +use std::error::Error; + +/// Sudoless performance monitoring CLI tool for Apple Silicon processors +/// https://github.com/vladkens/macmon +#[derive(Debug, Parser)] +#[command(version, verbatim_doc_comment)] +struct Cli { + /// Update interval in milliseconds + #[arg(short, long, default_value_t = 1000)] + interval: u64, + + /// Print raw data instead of TUI + #[arg(long, default_value_t = false)] + raw: bool, +} + +fn main() -> Result<(), Box> { + let args = Cli::parse(); + let info = metrics::initialize().unwrap(); + + if args.raw { + let mut subs = metrics::SubsChan::new(info)?; + loop { + let data = subs.sample(args.interval)?; + println!("{:?}", data); + } + } else { + let mut app = app::App::new(info); + app.run_loop(args.interval).unwrap(); + } + + Ok(()) +} diff --git a/src/metrics.rs b/src/metrics.rs new file mode 100644 index 0000000..3cf6f4d --- /dev/null +++ b/src/metrics.rs @@ -0,0 +1,492 @@ +use crate::cfutil::*; +use core_foundation::{ + array::{CFArrayGetCount, CFArrayGetValueAtIndex, CFArrayRef}, + base::{kCFAllocatorDefault, CFAllocatorRef, CFRange, CFRelease, CFTypeRef}, + data::{CFDataGetBytes, CFDataGetLength, CFDataRef}, + dictionary::{ + CFDictionaryCreateMutableCopy, CFDictionaryGetCount, CFDictionaryRef, CFMutableDictionaryRef, + }, + string::CFStringRef, +}; +use std::marker::{PhantomData, PhantomPinned}; +use std::mem::MaybeUninit; +use std::ptr::null; + +type WithError = Result>; + +const CPU_POWER_SUBG: &str = "CPU Complex Performance States"; +const GPU_POWER_SUBG: &str = "GPU Performance States"; + +#[rustfmt::skip] +#[link(name = "IOKit", kind = "framework")] +extern "C" { + fn IOServiceMatching(name: *const i8) -> CFMutableDictionaryRef; + fn IOServiceGetMatchingServices(mainPort: u32, matching: CFDictionaryRef, existing: *mut u32) -> i32; + fn IOIteratorNext(iterator: u32) -> u32; + fn IORegistryEntryGetName(entry: u32, name: *mut i8) -> i32; + fn IORegistryEntryCreateCFProperties(entry: u32, properties: *mut CFMutableDictionaryRef, allocator: CFAllocatorRef, options: u32) -> i32; + fn IOObjectRelease(obj: u32) -> u32; +} + +#[repr(C)] +struct IOReportSubscription { + _data: [u8; 0], + _phantom: PhantomData<(*mut u8, PhantomPinned)>, +} + +type IOReportSubscriptionRef = *const IOReportSubscription; + +#[rustfmt::skip] +#[link(name = "IOReport", kind = "dylib")] +extern "C" { + fn IOReportCopyAllChannels(a: u64, b: u64) -> CFDictionaryRef; + fn IOReportCopyChannelsInGroup(a: CFStringRef, b: CFStringRef, c: u64, d: u64, e: u64) -> CFDictionaryRef; + fn IOReportMergeChannels(a: CFDictionaryRef, b: CFDictionaryRef, nil: CFTypeRef); + fn IOReportCreateSubscription(a: CVoidRef, b: CFMutableDictionaryRef, c: *mut CFMutableDictionaryRef, d: u64, b: CFTypeRef) -> IOReportSubscriptionRef; + fn IOReportCreateSamples(a: IOReportSubscriptionRef, b: CFMutableDictionaryRef, c: CFTypeRef) -> CFDictionaryRef; + fn IOReportCreateSamplesDelta(a: CFDictionaryRef, b: CFDictionaryRef, c: CFTypeRef) -> CFDictionaryRef; + fn IOReportChannelGetChannelName(a: CFDictionaryRef) -> CFStringRef; + fn IOReportSimpleGetIntegerValue(a: CFDictionaryRef, b: i32) -> i64; + fn IOReportChannelGetUnitLabel(a: CFDictionaryRef) -> CFStringRef; + fn IOReportStateGetCount(a: CFDictionaryRef) -> i32; + fn IOReportStateGetNameForIndex(a: CFDictionaryRef, b: i32) -> CFStringRef; + fn IOReportStateGetResidency(a: CFDictionaryRef, b: i32) -> i64; +} + +fn cfio_iter_next(existing: u32) -> Option<(u32, String)> { + unsafe { + let el = IOIteratorNext(existing); + if el == 0 { + return None; + } + + let mut name = [0; 128]; // 128 defined in apple docs + if IORegistryEntryGetName(el, name.as_mut_ptr()) != 0 { + return None; + } + + let name = std::ffi::CStr::from_ptr(name.as_ptr()).to_string_lossy().to_string(); + Some((el, name)) + } +} + +fn cfio_get_props(entry: u32, name: String) -> WithError { + unsafe { + let mut props: MaybeUninit = MaybeUninit::uninit(); + if IORegistryEntryCreateCFProperties(entry, props.as_mut_ptr(), kCFAllocatorDefault, 0) != 0 { + return Err(format!("Failed to get properties for {}", name).into()); + } + + Ok(props.assume_init()) + } +} + +// dynamic voltage and frequency scaling +fn get_dvfs_mhz(dict: CFDictionaryRef, key: &str) -> (Vec, Vec) { + unsafe { + let obj = cfdict_get_val(dict, key).unwrap() as CFDataRef; + let obj_len = CFDataGetLength(obj); + let obj_val = vec![0u8; obj_len as usize]; + CFDataGetBytes(obj, CFRange::init(0, obj_len), obj_val.as_ptr() as *mut u8); + + // obj_val is pairs of (freq, voltage) 4 bytes each + let items_count = (obj_len / 8) as usize; + let [mut freqs, mut volts] = [vec![0u32; items_count], vec![0u32; items_count]]; + for (i, x) in obj_val.chunks_exact(8).enumerate() { + volts[i] = u32::from_le_bytes([x[4], x[5], x[6], x[7]]); + freqs[i] = u32::from_le_bytes([x[0], x[1], x[2], x[3]]); + freqs[i] = freqs[i] / 1000 / 1000; // as MHz + } + + (volts, freqs) + } +} + +// General info + +#[derive(Debug, Default, Clone)] +pub struct SocInfo { + pub chip_name: String, + pub memory_gb: u8, + pub ecpu_cores: u8, + pub pcpu_cores: u8, + pub gpu_cores: u8, + pub ecpu_freqs: Vec, + pub pcpu_freqs: Vec, + pub gpu_freqs: Vec, +} + +fn fill_basic_info(info: &mut SocInfo) -> WithError<()> { + // system_profiler -listDataTypes + let out = std::process::Command::new("system_profiler") + .args(&["SPHardwareDataType", "SPDisplaysDataType", "-json"]) + .output() + .unwrap(); + + let out = std::str::from_utf8(&out.stdout).unwrap(); + let out = serde_json::from_str::(out).unwrap(); + + // SPHardwareDataType.0.chip_type + let chip_name = out["SPHardwareDataType"][0]["chip_type"].as_str().unwrap().to_string(); + + // SPHardwareDataType.0.physical_memory -> "x GB" + let mem_gb = out["SPHardwareDataType"][0]["physical_memory"].as_str(); + let mem_gb = mem_gb.expect("No memory found").strip_suffix(" GB").unwrap(); + let mem_gb = mem_gb.parse::().unwrap(); + + // SPHardwareDataType.0.number_processors -> "proc x:y:z" + let cpu_cores = out["SPHardwareDataType"][0]["number_processors"].as_str(); + let cpu_cores = cpu_cores.expect("No CPU cores found").strip_prefix("proc ").unwrap(); + let cpu_cores = cpu_cores.split(':').map(|x| x.parse::().unwrap()).collect::>(); + assert_eq!(cpu_cores.len(), 3, "Invalid number of CPU cores"); + let (ecpu_cores, pcpu_cores, _) = (cpu_cores[2], cpu_cores[1], cpu_cores[0]); + + let gpu_cores = match out["SPDisplaysDataType"][0]["sppci_cores"].as_str() { + Some(x) => x.parse::().unwrap(), + None => 0, + }; + + info.chip_name = chip_name; + info.memory_gb = mem_gb as u8; + info.gpu_cores = gpu_cores as u8; + info.ecpu_cores = ecpu_cores as u8; + info.pcpu_cores = pcpu_cores as u8; + + Ok(()) +} + +fn fill_cores_info(info: &mut SocInfo) -> WithError<()> { + unsafe { + let service_name = std::ffi::CString::new("AppleARMIODevice").unwrap(); + let service = IOServiceMatching(service_name.as_ptr() as *const i8); + + let mut existing = 0; + if IOServiceGetMatchingServices(0, service, &mut existing) != 0 { + return Err("AppleARMIODevice not found".into()); + } + + // println!("Found {} services", existing); + while let Some(obj) = cfio_iter_next(existing) { + let (entry, name) = obj; + // println!("Found service ({:?}): {}", entry, name); + + if name == "pmgr" { + let item = cfio_get_props(entry, name)?; + // let keys = cfdict_keys(item); + // println!("Keys: {:?}", keys); + // CFShow(item as _); + + info.ecpu_freqs = get_dvfs_mhz(item, "voltage-states1-sram").1; + info.pcpu_freqs = get_dvfs_mhz(item, "voltage-states5-sram").1; + info.gpu_freqs = get_dvfs_mhz(item, "voltage-states9").1; + + CFRelease(item as _); + } + } + + IOObjectRelease(existing); + } + + if info.ecpu_freqs.len() == 0 || info.pcpu_freqs.len() == 0 { + return Err("No CPU cores found".into()); + } + + Ok(()) +} + +pub fn initialize() -> WithError { + let mut info = SocInfo::default(); + fill_basic_info(&mut info)?; + fill_cores_info(&mut info)?; + Ok(info) +} + +// Memory + +fn libc_ram_info() -> WithError<(u64, u64)> { + let (mut usage, mut total) = (0u64, 0u64); + + unsafe { + let mut name = [libc::CTL_HW, libc::HW_MEMSIZE]; + let mut size = std::mem::size_of::(); + let ret_code = libc::sysctl( + name.as_mut_ptr(), + name.len() as _, + &mut total as *mut _ as *mut _, + &mut size, + std::ptr::null_mut(), + 0, + ); + + if ret_code != 0 { + return Err("Failed to get total memory".into()); + } + } + + unsafe { + let mut count: u32 = libc::HOST_VM_INFO64_COUNT as _; + let mut stats = std::mem::zeroed::(); + + let ret_code = libc::host_statistics64( + libc::mach_host_self(), + libc::HOST_VM_INFO64, + &mut stats as *mut _ as *mut _, + &mut count, + ); + + if ret_code != 0 { + return Err("Failed to get memory stats".into()); + } + + let page_size_kb = libc::sysconf(libc::_SC_PAGESIZE) as u64; + + usage = (0 + + stats.active_count as u64 + + stats.inactive_count as u64 + + stats.wire_count as u64 + + stats.speculative_count as u64 + + stats.compressor_page_count as u64 + - stats.purgeable_count as u64 + - stats.external_page_count as u64 + + 0) + * page_size_kb; + } + + Ok((usage, total)) +} + +fn libc_swap_info() -> WithError<(u64, u64)> { + let (mut usage, mut total) = (0u64, 0u64); + + unsafe { + let mut name = [libc::CTL_VM, libc::VM_SWAPUSAGE]; + let mut size = std::mem::size_of::(); + let mut xsw: libc::xsw_usage = std::mem::zeroed::(); + + let ret_code = libc::sysctl( + name.as_mut_ptr(), + name.len() as _, + &mut xsw as *mut _ as *mut _, + &mut size, + std::ptr::null_mut(), + 0, + ); + + if ret_code != 0 { + return Err("Failed to get swap usage".into()); + } + + usage = xsw.xsu_used; + total = xsw.xsu_total; + } + + Ok((usage, total)) +} + +// Metrics collector + +unsafe fn cfio_get_chan(items: Vec<(&str, Option<&str>)>) -> WithError { + // if no items are provided, return all channels + if items.len() == 0 { + let c = IOReportCopyAllChannels(0, 0); + let r = CFDictionaryCreateMutableCopy(kCFAllocatorDefault, CFDictionaryGetCount(c), c); + CFRelease(c as _); + return Ok(r); + } + + let mut channels = vec![]; + for (group, subgroup) in items { + let gname = cfstr(group); + let sname = subgroup.map_or(null(), |x| cfstr(x)); + let chan = IOReportCopyChannelsInGroup(gname, sname, 0, 0, 0); + channels.push(chan); + + CFRelease(gname as _); + if subgroup.is_some() { + CFRelease(sname as _); + } + } + + let chan = channels[0]; + for i in 1..channels.len() { + IOReportMergeChannels(chan, channels[i], null()); + } + + let size = CFDictionaryGetCount(chan); + let chan = CFDictionaryCreateMutableCopy(kCFAllocatorDefault, size, chan); + + for i in 0..channels.len() { + CFRelease(channels[i] as _); + } + + if cfdict_get_val(chan, "IOReportChannels").is_none() { + return Err("Failed to get channels".into()); + } + + Ok(chan) +} + +unsafe fn cfio_get_subs(chan: CFMutableDictionaryRef) -> WithError { + let mut s: MaybeUninit = MaybeUninit::uninit(); + let rs = IOReportCreateSubscription(std::ptr::null(), chan, s.as_mut_ptr(), 0, std::ptr::null()); + if rs == std::ptr::null() { + return Err("Failed to create subscription".into()); + } + + s.assume_init(); + Ok(rs) +} + +#[derive(Debug, Default)] +pub struct MemoryUsage { + pub ram_total: u64, // bytes + pub ram_usage: u64, // bytes + pub swap_total: u64, // bytes + pub swap_usage: u64, // bytes +} + +#[derive(Debug, Default)] +pub struct PerfSample { + pub ecpu_usage: (u32, f32), // freq, percent_from_max + pub pcpu_usage: (u32, f32), // freq, percent_from_max + pub gpu_usage: (u32, f32), // freq, percent_from_max + pub all_watts: f32, // W + pub cpu_power: f32, // W + pub gpu_power: f32, // W + pub ane_power: f32, // W + pub memory: MemoryUsage, +} + +unsafe fn calc_freq(item: CFDictionaryRef, freqs: &Vec) -> (u32, f32) { + let count = IOReportStateGetCount(item) as usize; + assert!(count > freqs.len(), "Invalid freqs count"); // todo? + + let mut residencies = vec![0; count]; + for i in 0..count as i32 { + let val = IOReportStateGetResidency(item, i); + residencies[i as usize] = val as u64; + + let _key = from_cfstr(IOReportStateGetNameForIndex(item, i)); + // println!("{} {}: {}", i, _key, val) + } + + let count = freqs.len(); + let total = residencies.iter().sum::(); + let usage = residencies.iter().skip(1).sum::(); // first is IDLE for CPU and OFF for GPU + + let mut freq = 0f64; + for i in 0..count { + let percent = match usage { + 0 => 0.0, + _ => residencies[i + 1] as f64 / usage as f64, + }; + + freq += percent * freqs[i] as f64; + } + + let percent = usage as f64 / total as f64; + let max_freq = freqs.last().unwrap().clone() as f64; + let from_max = (freq * percent) / max_freq; + + (freq as u32, from_max as f32) +} + +fn get_watts(item: CFDictionaryRef, unit: &String, duration: u64) -> WithError { + let val = unsafe { IOReportSimpleGetIntegerValue(item, 0) } as f32; + let val = val / (duration as f32 / 1000.0); + match unit.as_str() { + "mJ" => Ok(val / 1e3f32), + "nJ" => Ok(val / 1e9f32), + _ => Err(format!("Invalid energy unit: {}", unit).into()), + } +} + +unsafe fn cfio_parse_sample( + subs: IOReportSubscriptionRef, + chan: CFMutableDictionaryRef, + info: &SocInfo, + duration: u64, +) -> WithError { + let sample1 = IOReportCreateSamples(subs, chan, null()); + std::thread::sleep(std::time::Duration::from_millis(duration)); + let sample2 = IOReportCreateSamples(subs, chan, null()); + + let sample = IOReportCreateSamplesDelta(sample1, sample2, null()); + CFRelease(sample1 as _); + CFRelease(sample2 as _); + + let mut rs = PerfSample::default(); + let na = "na".to_string(); + + let items = cfdict_get_val(sample, "IOReportChannels").unwrap() as CFArrayRef; + for i in 0..CFArrayGetCount(items) { + let item = CFArrayGetValueAtIndex(items, i) as CFDictionaryRef; + + let gname = cfdict_get_str(item, "IOReportGroupName").unwrap_or(na.clone()); + let sname = cfdict_get_str(item, "IOReportSubGroupName").unwrap_or(na.clone()); + let cname = from_cfstr(IOReportChannelGetChannelName(item)); + let _unit = from_cfstr(IOReportChannelGetUnitLabel(item)); + + if gname == "CPU Stats" || gname == "GPU Stats" { + match (sname.as_str(), cname.as_str()) { + (CPU_POWER_SUBG, "ECPU") => rs.ecpu_usage = calc_freq(item, &info.ecpu_freqs), + (CPU_POWER_SUBG, "PCPU") => rs.pcpu_usage = calc_freq(item, &info.pcpu_freqs), + (GPU_POWER_SUBG, "GPUPH") => rs.gpu_usage = calc_freq(item, &info.gpu_freqs[1..].to_vec()), + _ => {} + } + } + + if gname == "Energy Model" { + // ultra chip is two joined max chips + match cname.as_str() { + "CPU Energy" => rs.cpu_power += get_watts(item, &_unit, duration)?, + "GPU Energy" => rs.gpu_power += get_watts(item, &_unit, duration)?, + x if x.starts_with("ANE") => rs.ane_power += get_watts(item, &_unit, duration)?, + _ => (), + } + } + } + + CFRelease(sample as _); + rs.all_watts = rs.cpu_power + rs.gpu_power; + Ok(rs) +} + +pub struct SubsChan { + subs: IOReportSubscriptionRef, + chan: CFMutableDictionaryRef, + info: SocInfo, +} + +impl Drop for SubsChan { + fn drop(&mut self) { + unsafe { + CFRelease(self.subs as _); + CFRelease(self.chan as _); + } + } +} + +impl SubsChan { + pub fn new(info: SocInfo) -> WithError { + let channels = vec![ + ("Energy Model", None), // cpu+gpu+ane power + ("CPU Stats", Some(CPU_POWER_SUBG)), // cpu freq by cluster + ("GPU Stats", Some(GPU_POWER_SUBG)), // gpu freq + ]; + + let chan = unsafe { cfio_get_chan(channels)? }; + let subs = unsafe { cfio_get_subs(chan)? }; + Ok(Self { subs, chan, info }) + } + + pub fn sample(&mut self, duration: u64) -> WithError { + let (ram_usage, ram_total) = libc_ram_info()?; + let (swap_usage, swap_total) = libc_swap_info()?; + + let mut res = unsafe { cfio_parse_sample(self.subs, self.chan, &self.info, duration) }?; + res.memory = MemoryUsage { ram_total, ram_usage, swap_total, swap_usage }; + + Ok(res) + } +}