diff --git a/.gitignore b/.gitignore index ff60d79..0757c80 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ result result-* stats-daemon/target/ +plugin/target/ diff --git a/README.md b/README.md index 2145d79..3cecb76 100644 --- a/README.md +++ b/README.md @@ -60,17 +60,16 @@ imports = [ inputs.nova-shell.nixosModules.default ]; You poor thing. Here's what the Nix packaging does for you, manually: 1. Build [quickshell](https://quickshell.outfoxxed.me) from source. You'll need Qt 6. -2. Build `nova-stats` from `stats-daemon/` (`cargo build --release`). Put the binary in your PATH. +2. Build the stats plugin from `plugin/` (`cargo build --release`). Copy `libnova_plugin.so` and `target/qml_modules/NovaStats/qmldir` into a `NovaStats/` directory on Qt's QML import path. Set `QMAKE` to your `qmake6` before building. 3. Put `gdbus` (from glib) in your PATH. The lock screen needs it. 4. Compile the shaders: `for f in shell/modules/*.frag; do qsb --qt6 -o "${f}.qsb" "$f"; done` 5. Install [Symbols Nerd Font](https://www.nerdfonts.com/). 6. Create `~/.config/nova-shell/theme.json` and `~/.config/nova-shell/modules.json`. The Nix module generates these from your config. Without it, you write JSON by hand like a human. See the theme and module tables below for keys. -7. Run: `quickshell -p /path/to/nova-shell/shell/shell.qml` +7. Run: `quickshell -I /path/to/qml-modules -p /path/to/nova-shell/shell/shell.qml` Optional runtime dependencies, depending on which modules you enable: - `wttrbar` for weather - `cava` for the audio visualizer on album art -- `coreutils` (df) for disk usage If something breaks, the Nix module would have handled it. You chose this path. I respect it in the same way I respect people who free-climb skyscrapers. diff --git a/flake.nix b/flake.nix index 6ca5807..33cbc64 100644 --- a/flake.nix +++ b/flake.nix @@ -74,12 +74,13 @@ }; nova-stats = pkgs.callPackage ./nix/stats-daemon.nix { }; nova-shaders = pkgs.callPackage ./nix/shaders.nix { }; + nova-plugin = pkgs.callPackage ./nix/plugin.nix { }; in rec { - inherit nova-stats nova-shaders; + inherit nova-stats nova-shaders nova-plugin; nova-shell = pkgs.callPackage ./nix/package.nix { quickshell = qs; - inherit nova-stats nova-shaders; + inherit nova-stats nova-shaders nova-plugin; }; nova-shell-cli = pkgs.runCommand "nova-shell-cli" { nativeBuildInputs = [ pkgs.makeWrapper ]; } '' mkdir -p $out/bin @@ -99,7 +100,7 @@ cd $src export QML_IMPORT_PATH="${rawPkgs.qt6.qtdeclarative}/lib/qt-6/qml:${ quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default - }/lib/qt-6/qml" + }/lib/qt-6/qml:${self.packages.${pkgs.stdenv.hostPlatform.system}.nova-plugin}/lib/qt-6/qml" qmllint -E \ -I shell/modules -I shell/services -I shell/applets -I shell/dock -I shell/lock \ shell/shell.qml shell/modules/*.qml shell/services/*.qml \ @@ -126,6 +127,7 @@ quickshell = qsUnpatched; nova-stats = rawPkgs.callPackage ./nix/stats-daemon.nix { }; nova-shaders = rawPkgs.callPackage ./nix/shaders.nix { }; + nova-plugin = rawPkgs.callPackage ./nix/plugin.nix { }; }; } ); @@ -142,7 +144,10 @@ rustfmt libnotify qt6.qtdeclarative + qt6.qtbase + pkg-config ]; + QMAKE = "${rawPkgs.qt6.qtbase}/bin/qmake6"; }; } ); @@ -157,6 +162,7 @@ formatting = treefmt-eval.config.build.check self; build = self.packages.${pkgs.stdenv.hostPlatform.system}.default; nova-stats = self.packages.${pkgs.stdenv.hostPlatform.system}.nova-stats; + nova-plugin = self.packages.${pkgs.stdenv.hostPlatform.system}.nova-plugin; docs = self.packages.${pkgs.stdenv.hostPlatform.system}.docs; qmllint = let diff --git a/nix/package.nix b/nix/package.nix index 93c9e3d..dd796d6 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -6,6 +6,7 @@ quickshell, nova-stats, nova-shaders, + nova-plugin, glib, }: let @@ -38,6 +39,7 @@ symlinkJoin { paths = [ shell nova-shaders + nova-plugin ]; nativeBuildInputs = [ makeWrapper ]; @@ -46,6 +48,7 @@ symlinkJoin { mkdir -p $out/bin makeWrapper ${lib.getExe quickshell} $out/bin/nova-shell \ --add-flags "-p $out/share/nova-shell/shell.qml" \ + --add-flags "-I $out/lib/qt-6/qml" \ --prefix PATH : ${ lib.makeBinPath [ nova-stats diff --git a/nix/plugin.nix b/nix/plugin.nix new file mode 100644 index 0000000..8298ff8 --- /dev/null +++ b/nix/plugin.nix @@ -0,0 +1,79 @@ +{ + lib, + rustPlatform, + pkg-config, + qt6, + writeShellScript, + runCommand, +}: +let + # nixpkgs splits Qt tools across packages: moc/rcc/qtpaths live in qtbase, + # qmltyperegistrar and qmlcachegen live in qtdeclarative. qt-build-utils + # finds tools via `qmake -query QT_INSTALL_LIBEXECS`, which only returns + # qtbase paths, so those two tools are invisible. Fix: combine them into a + # single symlink tree and point a qmake wrapper at it. + qtBuildTools = runCommand "qt6-build-tools" { } '' + mkdir -p $out/libexec $out/bin + for f in ${qt6.qtbase}/libexec/* ${qt6.qtbase}/bin/*; do + ln -sf "$f" "$out/$(echo "$f" | grep -o 'libexec\|bin')/$(basename "$f")" + done + for tool in qmltyperegistrar qmlcachegen; do + src="${qt6.qtdeclarative}/libexec/$tool" + [ -f "$src" ] && ln -sf "$src" "$out/libexec/$tool" + done + ''; + qmakeWrapper = writeShellScript "qmake6" '' + if [ "$1" = "-query" ]; then + case "$2" in + QT_HOST_LIBEXECS|QT_HOST_LIBEXECS/get|QT_INSTALL_LIBEXECS|QT_INSTALL_LIBEXECS/get) + echo "${qtBuildTools}/libexec"; exit 0;; + QT_HOST_BINS|QT_HOST_BINS/get|QT_INSTALL_BINS|QT_INSTALL_BINS/get) + echo "${qtBuildTools}/bin"; exit 0;; + esac + fi + exec ${qt6.qtbase}/bin/qmake6 "$@" + ''; +in +rustPlatform.buildRustPackage { + pname = "nova-plugin"; + version = "0.1.0"; + src = lib.cleanSource ../plugin; + cargoLock.lockFile = ../plugin/Cargo.lock; + + nativeBuildInputs = [ + pkg-config + qt6.qtbase + qt6.qtdeclarative + ]; + + buildInputs = [ + qt6.qtbase + qt6.qtdeclarative + ]; + + dontWrapQtApps = true; + + # qt6.qtbase's setup hook overrides QMAKE after derivation attrs are set, + # so re-assert it in preBuild which runs after all setup hooks. + preBuild = '' + export QMAKE=${qmakeWrapper} + ''; + + installPhase = '' + runHook preInstall + + qml_dir="$out/lib/qt-6/qml/NovaStats" + mkdir -p "$qml_dir" + + install -m755 target/*/release/libnova_plugin.so "$qml_dir/" + install -m644 target/*/cxxqt/qml_modules/NovaStats/qmldir "$qml_dir/" + install -m644 target/*/cxxqt/qml_modules/NovaStats/plugin.qmltypes "$qml_dir/" 2>/dev/null || true + + runHook postInstall + ''; + + meta = { + description = "In-process system stats QML plugin for nova-shell"; + platforms = lib.platforms.linux; + }; +} diff --git a/plugin/Cargo.lock b/plugin/Cargo.lock new file mode 100644 index 0000000..078a0bc --- /dev/null +++ b/plugin/Cargo.lock @@ -0,0 +1,571 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +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 = "clang-format" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "696283b40e1a39d208ee614b92e5f6521d16962edeb47c48372585ec92419943" +dependencies = [ + "thiserror", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width 0.1.14", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width 0.2.2", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cxx" +version = "1.0.194" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "747d8437319e3a2f43d93b341c137927ca70c0f5dabeea7a005a73665e247c7e" +dependencies = [ + "cc", + "cxx-build", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.194" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f4697d190a142477b16aef7da8a99bfdc41e7e8b1687583c0d23a79c7afc1e" +dependencies = [ + "cc", + "codespan-reporting 0.13.1", + "indexmap", + "proc-macro2", + "quote", + "scratch", + "syn", +] + +[[package]] +name = "cxx-gen" +version = "0.7.194" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "035b6c61a944483e8a4b2ad4fb8b13830d63491bd004943716ad16d85dcc64bc" +dependencies = [ + "codespan-reporting 0.13.1", + "indexmap", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cxx-qt" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdf26e5ee4375a85799d0fe436662e5a8ef099bd0964b84bce8812c35c7daea" +dependencies = [ + "cxx", + "cxx-qt-build", + "cxx-qt-macro", + "qt-build-utils", + "static_assertions", + "thiserror", +] + +[[package]] +name = "cxx-qt-build" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a88c7f8241bfe4dfd19d27397a5845f0f275735a34ee7fb219045da81945e3" +dependencies = [ + "cc", + "codespan-reporting 0.11.1", + "cxx-gen", + "cxx-qt-gen", + "proc-macro2", + "qt-build-utils", + "quote", + "serde", + "serde_json", +] + +[[package]] +name = "cxx-qt-gen" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b1917c90b10117890e3794f4a0dc764a24704de40711d688ae128b0704490b8" +dependencies = [ + "clang-format", + "convert_case", + "cxx-gen", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cxx-qt-lib" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b3de184cff59ad29d657021f933380e584d9b70265247f6a96098bef964211" +dependencies = [ + "cxx", + "cxx-qt", + "cxx-qt-build", + "qt-build-utils", +] + +[[package]] +name = "cxx-qt-macro" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230b0d8b20d3d02a85885742be4fabc0382fead4e382dd8c9c9ff1827f221e8c" +dependencies = [ + "cxx-qt-gen", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.194" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0956799fa8678d4c50eed028f2de1c0552ae183c76e976cf7ca8c4e36a7c328" +dependencies = [ + "clap", + "codespan-reporting 0.13.1", + "indexmap", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.194" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23384a836ab4f0ad98ace7e3955ad2de39de42378ab487dc28d3990392cb283a" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.194" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6acc6b5822b9526adfb4fc377b67128fdd60aac757cc4a741a6278603f763cf" +dependencies = [ + "indexmap", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "link-cplusplus" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" +dependencies = [ + "cc", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nova-plugin" +version = "0.1.0" +dependencies = [ + "cxx", + "cxx-qt", + "cxx-qt-build", + "cxx-qt-lib", + "libc", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "qt-build-utils" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d074750fd3baba12fdb47388d591ad9ed043e23864a79d129dcf3a1ef6fc8d9" +dependencies = [ + "anyhow", + "cc", + "semver", + "serde", + "thiserror", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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 = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scratch" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" + +[[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.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[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", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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 = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/plugin/Cargo.toml b/plugin/Cargo.toml new file mode 100644 index 0000000..81f7021 --- /dev/null +++ b/plugin/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "nova-plugin" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +cast_possible_truncation = "allow" +cast_precision_loss = "allow" +cast_sign_loss = "allow" + +[dependencies] +cxx = "1.0" +cxx-qt = "0.8.1" +cxx-qt-lib = { version = "0.8.1", features = ["qt_full"] } +libc = "0.2" + +[build-dependencies] +cxx-qt-build = "0.8.1" diff --git a/plugin/build.rs b/plugin/build.rs new file mode 100644 index 0000000..117d998 --- /dev/null +++ b/plugin/build.rs @@ -0,0 +1,7 @@ +use cxx_qt_build::{CxxQtBuilder, PluginType, QmlModule}; + +fn main() { + CxxQtBuilder::new_qml_module(QmlModule::new("NovaStats").plugin_type(PluginType::Dynamic)) + .files(["src/system_stats.rs", "src/cpu_service.rs"]) + .build(); +} diff --git a/plugin/src/cpu_service.rs b/plugin/src/cpu_service.rs new file mode 100644 index 0000000..21b7b67 --- /dev/null +++ b/plugin/src/cpu_service.rs @@ -0,0 +1,269 @@ +use crate::stats::cpu; +use core::pin::Pin; +use cxx_qt::CxxQtType; +use cxx_qt_lib::{QList, QString}; + +#[cxx_qt::bridge] +pub mod qobject { + unsafe extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + + include!("cxx-qt-lib/core/qlist/qlist_i32.h"); + type QList_i32 = cxx_qt_lib::QList; + + include!("cxx-qt-lib/core/qlist/qlist_f64.h"); + type QList_f64 = cxx_qt_lib::QList; + + include!("cxx-qt-lib/core/qlist/qlist_QString.h"); + type QList_QString = cxx_qt_lib::QList; + } + + extern "RustQt" { + #[qobject] + #[qml_element] + #[qml_singleton] + #[qproperty(i32, usage)] + #[qproperty(f64, freq_ghz, cxx_name = "freqGhz")] + #[qproperty(QList_i32, history)] + // JSON strings: [{usage, freq_ghz, history:[]}] + #[qproperty(QList_QString, cores)] + #[qproperty(QList_f64, core_max_freq, cxx_name = "coreMaxFreq")] + #[qproperty(QList_QString, core_types, cxx_name = "coreTypes")] + #[qproperty(bool, enable_core_history, cxx_name = "enableCoreHistory")] + type CpuService = super::CpuServiceRust; + + #[qinvokable] + fn poll(self: Pin<&mut Self>); + + #[qinvokable] + #[cxx_name = "clearCoreHistory"] + fn clear_core_history(self: Pin<&mut Self>); + } + + impl cxx_qt::Initialize for CpuService {} +} + +pub struct CpuServiceRust { + prev_stat: Vec, + history_overall: Vec, + core_history: Vec>, + + usage: i32, + freq_ghz: f64, + history: QList, + cores: QList, + core_max_freq: QList, + core_types: QList, + enable_core_history: bool, +} + +impl Default for CpuServiceRust { + fn default() -> Self { + Self { + prev_stat: Vec::new(), + history_overall: Vec::new(), + core_history: Vec::new(), + usage: 0, + freq_ghz: 0.0, + history: QList::default(), + cores: QList::default(), + core_max_freq: QList::default(), + core_types: QList::default(), + enable_core_history: false, + } + } +} + +impl cxx_qt::Initialize for qobject::CpuService { + fn initialize(mut self: Pin<&mut Self>) { + let max_freqs = read_core_max_freqs(); + let types = infer_core_types(&max_freqs); + + let mut mf_list = QList::::default(); + for f in &max_freqs { + mf_list.append(*f); + } + self.as_mut().set_core_max_freq(mf_list); + + let ct_list = strings_to_qlist(&types); + self.as_mut().set_core_types(ct_list); + } +} + +impl qobject::CpuService { + fn poll(mut self: Pin<&mut Self>) { + let curr = cpu::read_stat(); + let freqs = cpu::read_core_freqs(); + let prev = self.as_ref().rust().prev_stat.clone(); + + let Some(snap) = cpu::compute_snapshot(&prev, &curr, &freqs) else { + self.as_mut().rust_mut().prev_stat = curr; + return; + }; + self.as_mut().rust_mut().prev_stat = curr; + + let usage = snap.usage as i32; + let freq_ghz = snap.freq_ghz; + let enable = self.as_ref().rust().enable_core_history; + let n_cores = snap.core_usage.len().max(freqs.len()); + + { + let mut r = self.as_mut().rust_mut(); + push_capped(&mut r.history_overall, usage, 60); + if r.core_history.len() < n_cores { + r.core_history.resize(n_cores, Vec::new()); + } + if enable { + for (i, &u) in snap.core_usage.iter().enumerate() { + push_capped(&mut r.core_history[i], u as i32, 16); + } + } + } + + let history_qlist = vec_to_qlist_i32(&self.as_ref().rust().history_overall); + let cores_qlist = + build_cores_qlist(&snap, &freqs, &self.as_ref().rust().core_history, enable); + + self.as_mut().set_usage(usage); + self.as_mut().set_freq_ghz(freq_ghz); + self.as_mut().set_history(history_qlist); + self.as_mut().set_cores(cores_qlist); + } + + fn clear_core_history(mut self: Pin<&mut Self>) { + for v in &mut self.as_mut().rust_mut().core_history { + v.clear(); + } + } +} + +fn build_cores_qlist( + snap: &cpu::CpuSnapshot, + freqs: &[f64], + core_history: &[Vec], + enable: bool, +) -> QList { + let n = snap.core_usage.len().max(freqs.len()); + let mut list = QList::::default(); + for i in 0..n { + let u = snap.core_usage.get(i).copied().unwrap_or(0) as i32; + let f = freqs.get(i).copied().unwrap_or(0.0); + let hist_slice = if enable { + core_history.get(i).map(Vec::as_slice).unwrap_or(&[]) + } else { + &[] + }; + let hist_str = hist_slice + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(","); + let json = format!( + "{{\"usage\":{},\"freq_ghz\":{:.3},\"history\":[{}]}}", + u, f, hist_str + ); + list.append(QString::from(json.as_str())); + } + list +} + +fn push_capped(v: &mut Vec, val: i32, max: usize) { + v.push(val); + if v.len() > max { + v.drain(..v.len() - max); + } +} + +fn vec_to_qlist_i32(v: &[i32]) -> QList { + let mut list = QList::::default(); + for &x in v { + list.append(x); + } + list +} + +fn strings_to_qlist(types: &[String]) -> QList { + let mut list = QList::::default(); + for t in types { + list.append(QString::from(t.as_str())); + } + list +} + +fn read_core_max_freqs() -> Vec { + let mut freqs = Vec::new(); + for i in 0.. { + let path = format!("/sys/devices/system/cpu/cpu{i}/cpufreq/cpuinfo_max_freq"); + match std::fs::read_to_string(&path) { + Ok(s) => match s.trim().parse::() { + Ok(khz) => freqs.push(khz as f64 / 1_000_000.0), + Err(_) => break, + }, + Err(_) => break, + } + } + freqs +} + +fn infer_core_types(max_freqs: &[f64]) -> Vec { + if let Some(types) = read_hybrid_topology(max_freqs.len()) { + return types; + } + let freqs: Vec = max_freqs.iter().copied().filter(|&f| f > 0.0).collect(); + if freqs.len() < 2 { + return Vec::new(); + } + let max_f = freqs.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let min_f = freqs.iter().cloned().fold(f64::INFINITY, f64::min); + if max_f <= 0.0 || (max_f - min_f) / max_f <= 0.15 { + return Vec::new(); + } + let threshold = (max_f + min_f) / 2.0; + max_freqs + .iter() + .map(|&f| { + if f >= threshold { + "Performance".to_string() + } else { + "Efficiency".to_string() + } + }) + .collect() +} + +fn read_hybrid_topology(n_cores: usize) -> Option> { + let core_cpus = std::fs::read_to_string("/sys/devices/cpu_core/cpus").ok()?; + let atom_cpus = std::fs::read_to_string("/sys/devices/cpu_atom/cpus").ok()?; + let p_cores = expand_cpu_range(core_cpus.trim()); + let e_cores = expand_cpu_range(atom_cpus.trim()); + let max_cpu = p_cores.iter().chain(e_cores.iter()).copied().max()?; + let count = (max_cpu + 1).max(n_cores); + Some( + (0..count) + .map(|i| { + if e_cores.contains(&i) { + "Efficiency".to_string() + } else { + "Performance".to_string() + } + }) + .collect(), + ) +} + +fn expand_cpu_range(s: &str) -> std::collections::HashSet { + let mut set = std::collections::HashSet::new(); + for part in s.split(',') { + if let Some((a, b)) = part.split_once('-') { + if let (Ok(lo), Ok(hi)) = (a.trim().parse::(), b.trim().parse::()) { + for i in lo..=hi { + set.insert(i); + } + } + } else if let Ok(n) = part.trim().parse::() { + set.insert(n); + } + } + set +} diff --git a/plugin/src/lib.rs b/plugin/src/lib.rs new file mode 100644 index 0000000..f0fd243 --- /dev/null +++ b/plugin/src/lib.rs @@ -0,0 +1,3 @@ +pub mod cpu_service; +pub mod stats; +pub mod system_stats; diff --git a/plugin/src/stats/cpu.rs b/plugin/src/stats/cpu.rs new file mode 100644 index 0000000..15a456f --- /dev/null +++ b/plugin/src/stats/cpu.rs @@ -0,0 +1,185 @@ +use std::fs; + +#[derive(Clone)] +pub struct Sample { + pub idle: u64, + pub total: u64, +} + +pub fn parse_stat(input: &str) -> Vec { + input + .lines() + .filter(|l| l.starts_with("cpu")) + .map(|l| { + let vals: Vec = l + .split_whitespace() + .skip(1) + .filter_map(|s| s.parse().ok()) + .collect(); + let idle = vals.get(3).copied().unwrap_or(0) + vals.get(4).copied().unwrap_or(0); + let total = vals.iter().sum(); + Sample { idle, total } + }) + .collect() +} + +pub fn pct(prev: &Sample, curr: &Sample) -> u32 { + let dt = curr.total.saturating_sub(prev.total); + let di = curr.idle.saturating_sub(prev.idle); + if dt == 0 { + return 0; + } + (dt.saturating_sub(di) * 100 / dt) as u32 +} + +pub fn read_stat() -> Vec { + parse_stat(&fs::read_to_string("/proc/stat").unwrap_or_default()) +} + +pub fn read_core_freqs() -> Vec { + let mut freqs = Vec::new(); + for i in 0.. { + let path = format!("/sys/devices/system/cpu/cpu{i}/cpufreq/scaling_cur_freq"); + match fs::read_to_string(&path) { + Ok(s) => match s.trim().parse::() { + Ok(khz) => freqs.push(khz as f64 / 1_000_000.0), + Err(_) => break, + }, + Err(_) => break, + } + } + freqs +} + +pub struct CpuSnapshot { + pub usage: u32, + pub freq_ghz: f64, + pub core_usage: Vec, +} + +pub fn compute_snapshot(prev: &[Sample], curr: &[Sample], freqs: &[f64]) -> Option { + if curr.is_empty() { + return None; + } + let usage = prev.first().zip(curr.first()).map_or(0, |(p, c)| pct(p, c)); + let core_usage: Vec = prev + .iter() + .skip(1) + .zip(curr.iter().skip(1)) + .map(|(p, c)| pct(p, c)) + .collect(); + let freq_ghz = if freqs.is_empty() { + 0.0 + } else { + freqs.iter().sum::() / freqs.len() as f64 + }; + Some(CpuSnapshot { + usage, + freq_ghz, + core_usage, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(idle: u64, total: u64) -> Sample { + Sample { idle, total } + } + + // ── pct ────────────────────────────────────────────────────────────── + + #[test] + fn pct_zero_delta_returns_zero() { + let s = Sample { + idle: 100, + total: 400, + }; + assert_eq!(pct(&s, &s), 0); + } + + #[test] + fn pct_all_idle() { + let prev = Sample { + idle: 0, + total: 100, + }; + let curr = Sample { + idle: 100, + total: 200, + }; + assert_eq!(pct(&prev, &curr), 0); + } + + #[test] + fn pct_fully_busy() { + let prev = Sample { + idle: 100, + total: 200, + }; + let curr = Sample { + idle: 100, + total: 300, + }; + assert_eq!(pct(&prev, &curr), 100); + } + + #[test] + fn pct_half_busy() { + let prev = Sample { idle: 0, total: 0 }; + let curr = Sample { + idle: 50, + total: 100, + }; + assert_eq!(pct(&prev, &curr), 50); + } + + #[test] + fn pct_no_underflow_on_backwards_clock() { + let prev = Sample { + idle: 200, + total: 400, + }; + let curr = Sample { + idle: 100, + total: 300, + }; + assert_eq!(pct(&prev, &curr), 0); + } + + // ── parse_stat ─────────────────────────────────────────────────────── + + const STAT_SAMPLE: &str = "\ +cpu 100 10 50 700 40 0 0 0 0 0 +cpu0 50 5 25 350 20 0 0 0 0 0 +cpu1 50 5 25 350 20 0 0 0 0 0"; + + #[test] + fn parse_stat_count() { + assert_eq!(parse_stat(STAT_SAMPLE).len(), 3); + } + + #[test] + fn parse_stat_aggregate_idle() { + assert_eq!(parse_stat(STAT_SAMPLE)[0].idle, 740); + } + + #[test] + fn parse_stat_aggregate_total() { + assert_eq!(parse_stat(STAT_SAMPLE)[0].total, 900); + } + + #[test] + fn parse_stat_per_core_idle() { + let s = parse_stat(STAT_SAMPLE); + assert_eq!(s[1].idle, 370); + assert_eq!(s[2].idle, 370); + } + + #[test] + fn parse_stat_ignores_non_cpu_lines() { + let input = "intr 12345\ncpu 1 2 3 4 5 0 0 0 0 0\npage 0 0"; + assert_eq!(parse_stat(input).len(), 1); + } +} diff --git a/plugin/src/stats/disk.rs b/plugin/src/stats/disk.rs new file mode 100644 index 0000000..6e012ff --- /dev/null +++ b/plugin/src/stats/disk.rs @@ -0,0 +1,80 @@ +use std::fs; + +pub struct DiskMount { + pub target: String, + pub pct: u32, + pub used_bytes: u64, + pub total_bytes: u64, +} + +const EXCLUDED_FS: &[&str] = &[ + "tmpfs", + "devtmpfs", + "squashfs", + "efivarfs", + "overlay", + "proc", + "sysfs", + "cgroup", + "cgroup2", + "devpts", + "hugetlbfs", + "mqueue", + "nsfs", + "pstore", + "rpc_pipefs", + "fusectl", + "tracefs", + "configfs", + "securityfs", + "binfmt_misc", + "autofs", +]; + +pub fn read_disk_mounts() -> Vec { + let content = fs::read_to_string("/proc/mounts").unwrap_or_default(); + let mut seen = std::collections::HashSet::new(); + let mut mounts = Vec::new(); + + for line in content.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 3 { + continue; + } + let target = parts[1]; + let fs_type = parts[2]; + if EXCLUDED_FS.contains(&fs_type) { + continue; + } + if !seen.insert(target.to_string()) { + continue; + } + if let Some((total, used)) = statvfs_bytes(target) { + let pct = (used * 100 / total) as u32; + mounts.push(DiskMount { + target: target.to_string(), + pct, + used_bytes: used, + total_bytes: total, + }); + } + } + mounts +} + +fn statvfs_bytes(path: &str) -> Option<(u64, u64)> { + use std::ffi::CString; + let cpath = CString::new(path).ok()?; + let mut st: libc::statvfs = unsafe { std::mem::zeroed() }; + let ret = unsafe { libc::statvfs(cpath.as_ptr(), &mut st) }; + if ret != 0 { + return None; + } + let bsize = st.f_frsize as u64; + let total = st.f_blocks as u64 * bsize; + let avail = st.f_bavail as u64 * bsize; + if total == 0 { + return None; + } + Some((total, total - avail)) +} diff --git a/plugin/src/stats/gpu.rs b/plugin/src/stats/gpu.rs new file mode 100644 index 0000000..1d94519 --- /dev/null +++ b/plugin/src/stats/gpu.rs @@ -0,0 +1,212 @@ +use std::fs; +use std::time::Instant; + +pub struct GpuInfo { + pub usage: u32, + pub vram_used_gb: f64, + pub vram_total_gb: f64, + pub temp_c: i32, + pub vendor: &'static str, +} + +pub enum GpuBackend { + Amd { + card_path: String, + hwmon_path: Option, + }, + Nvidia, + Intel { + card_path: String, + device_path: String, + hwmon_path: Option, + prev_rc6: Option<(u64, Instant)>, + }, + None, +} + +fn read_sysfs(path: &str) -> Option { + fs::read_to_string(path).ok().map(|s| s.trim().to_string()) +} + +fn read_sysfs_u64(path: &str) -> Option { + read_sysfs(path)?.parse().ok() +} + +fn find_hwmon(driver_name: &str) -> Option { + for i in 0..32 { + if let Some(name) = read_sysfs(&format!("/sys/class/hwmon/hwmon{i}/name")) { + if name == driver_name { + return Some(format!("/sys/class/hwmon/hwmon{i}")); + } + } + } + None +} + +pub fn detect_gpu() -> GpuBackend { + // AMD: look for gpu_busy_percent exposed by the amdgpu driver + for i in 0..8 { + let p = format!("/sys/class/drm/card{i}/device/gpu_busy_percent"); + if fs::read_to_string(&p).is_ok() { + let card = format!("/sys/class/drm/card{i}/device"); + let hwmon = find_hwmon("amdgpu"); + return GpuBackend::Amd { + card_path: card, + hwmon_path: hwmon, + }; + } + } + // NVIDIA: probe nvidia-smi + let nvidia_ok = std::process::Command::new("nvidia-smi") + .args(["--query-gpu=name", "--format=csv,noheader"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if nvidia_ok { + return GpuBackend::Nvidia; + } + // Intel: look for i915 or xe driver + for i in 0..8 { + let driver_link = format!("/sys/class/drm/card{i}/device/driver"); + if let Some(drv) = fs::read_link(&driver_link) + .ok() + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())) + { + if drv == "i915" || drv == "xe" { + let card_path = format!("/sys/class/drm/card{i}"); + let device_path = format!("/sys/class/drm/card{i}/device"); + let hwmon = find_hwmon(&drv); + return GpuBackend::Intel { + card_path, + device_path, + hwmon_path: hwmon, + prev_rc6: None, + }; + } + } + } + GpuBackend::None +} + +fn read_amd(card: &str, hwmon: Option<&String>) -> Option { + let usage: u32 = read_sysfs(&format!("{card}/gpu_busy_percent"))? + .parse() + .ok()?; + let vram_used: u64 = read_sysfs(&format!("{card}/mem_info_vram_used"))? + .parse() + .ok()?; + let vram_total: u64 = read_sysfs(&format!("{card}/mem_info_vram_total"))? + .parse() + .ok()?; + let temp_c = hwmon + .and_then(|h| read_sysfs(&format!("{h}/temp1_input"))) + .and_then(|s| s.parse::().ok()) + .map_or(0, |mc| mc / 1000); + Some(GpuInfo { + usage, + vram_used_gb: vram_used as f64 / 1_073_741_824.0, + vram_total_gb: vram_total as f64 / 1_073_741_824.0, + temp_c, + vendor: "amd", + }) +} + +fn read_nvidia() -> Option { + let out = std::process::Command::new("nvidia-smi") + .args([ + "--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu", + "--format=csv,noheader,nounits", + ]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout); + let p: Vec<&str> = s.trim().split(',').map(str::trim).collect(); + if p.len() < 4 { + return None; + } + Some(GpuInfo { + usage: p[0].parse().ok()?, + vram_used_gb: p[1].parse::().ok()? / 1024.0, + vram_total_gb: p[2].parse::().ok()? / 1024.0, + temp_c: p[3].parse().ok()?, + vendor: "nvidia", + }) +} + +fn read_intel( + card: &str, + device: &str, + hwmon: Option<&String>, + prev_rc6: &mut Option<(u64, Instant)>, +) -> GpuInfo { + let usage = read_intel_usage(card, prev_rc6).unwrap_or(0); + + // VRAM - only present on discrete GPUs (Arc) + let vram_total = read_sysfs_u64(&format!("{device}/mem_info_vram_total")).unwrap_or(0); + let vram_used = if vram_total > 0 { + read_sysfs_u64(&format!("{device}/mem_info_vram_used")).unwrap_or(0) + } else { + 0 + }; + + let temp_c = hwmon + .and_then(|h| read_sysfs(&format!("{h}/temp1_input"))) + .and_then(|s| s.parse::().ok()) + .map_or(0, |mc| mc / 1000); + + GpuInfo { + usage, + vram_used_gb: vram_used as f64 / 1_073_741_824.0, + vram_total_gb: vram_total as f64 / 1_073_741_824.0, + temp_c, + vendor: "intel", + } +} + +fn read_intel_usage(card: &str, prev_rc6: &mut Option<(u64, Instant)>) -> Option { + // RC6 is the GPU idle state - compute usage from residency delta + let rc6_ms = read_sysfs_u64(&format!("{card}/gt/gt0/rc6_residency_ms")) + .or_else(|| read_sysfs_u64(&format!("{card}/power/rc6_residency_ms")))?; + + let now = Instant::now(); + let usage = if let Some((prev_ms, prev_time)) = prev_rc6.take() { + let dt_ms = now.duration_since(prev_time).as_millis() as u64; + if dt_ms > 0 { + let idle_ms = rc6_ms.saturating_sub(prev_ms); + let idle_pct = (idle_ms * 100 / dt_ms).min(100); + (100 - idle_pct) as u32 + } else { + 0 + } + } else { + 0 // first reading, no delta yet + }; + + *prev_rc6 = Some((rc6_ms, now)); + Some(usage) +} + +pub fn read_gpu(backend: &mut GpuBackend) -> Option { + match backend { + GpuBackend::Amd { + card_path, + hwmon_path, + } => read_amd(card_path, hwmon_path.as_ref()), + GpuBackend::Nvidia => read_nvidia(), + GpuBackend::Intel { + card_path, + device_path, + hwmon_path, + prev_rc6, + } => Some(read_intel( + card_path, + device_path, + hwmon_path.as_ref(), + prev_rc6, + )), + GpuBackend::None => None, + } +} diff --git a/plugin/src/stats/mem.rs b/plugin/src/stats/mem.rs new file mode 100644 index 0000000..8435167 --- /dev/null +++ b/plugin/src/stats/mem.rs @@ -0,0 +1,104 @@ +use std::fs; + +pub struct MemInfo { + pub percent: u64, + pub used_gb: f64, + pub total_gb: f64, + pub avail_gb: f64, + pub cached_gb: f64, + pub buffers_gb: f64, +} + +pub fn parse_meminfo(input: &str) -> Option { + let mut total = 0u64; + let mut avail = 0u64; + let mut buffers = 0u64; + let mut cached = 0u64; + let mut sreclaimable = 0u64; + + for line in input.lines() { + let mut parts = line.splitn(2, ':'); + let key = parts.next().unwrap_or("").trim(); + let val: u64 = parts + .next() + .unwrap_or("") + .split_whitespace() + .next() + .unwrap_or("") + .parse() + .unwrap_or(0); + match key { + "MemTotal" => total = val, + "MemAvailable" => avail = val, + "Buffers" => buffers = val, + "Cached" => cached = val, + "SReclaimable" => sreclaimable = val, + _ => {} + } + } + + if total == 0 { + return None; + } + + let used = total.saturating_sub(avail); + let cached_total = cached + sreclaimable; + let gb = |kb: u64| kb as f64 / 1_048_576.0; + + Some(MemInfo { + percent: used * 100 / total, + used_gb: gb(used), + total_gb: gb(total), + avail_gb: gb(avail), + cached_gb: gb(cached_total), + buffers_gb: gb(buffers), + }) +} + +pub fn read_meminfo() -> Option { + parse_meminfo(&fs::read_to_string("/proc/meminfo").unwrap_or_default()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MEMINFO_SAMPLE: &str = "\ +MemTotal: 16384000 kB +MemFree: 2048000 kB +MemAvailable: 4096000 kB +Buffers: 512000 kB +Cached: 3072000 kB +SReclaimable: 512000 kB +SwapTotal: 8192000 kB +SwapFree: 8192000 kB"; + + #[test] + fn parse_meminfo_percent() { + let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); + assert_eq!(m.percent, 75); + } + + #[test] + fn parse_meminfo_total_gb() { + let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); + assert!((m.total_gb - 15.625).abs() < 0.001); + } + + #[test] + fn parse_meminfo_cached_includes_sreclaimable() { + let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); + let expected = 3_584_000.0 / 1_048_576.0; + assert!((m.cached_gb - expected).abs() < 0.001); + } + + #[test] + fn parse_meminfo_zero_total_returns_none() { + assert!(parse_meminfo("MemFree: 1000 kB\n").is_none()); + } + + #[test] + fn parse_meminfo_empty_returns_none() { + assert!(parse_meminfo("").is_none()); + } +} diff --git a/plugin/src/stats/mod.rs b/plugin/src/stats/mod.rs new file mode 100644 index 0000000..22f79cb --- /dev/null +++ b/plugin/src/stats/mod.rs @@ -0,0 +1,5 @@ +pub mod cpu; +pub mod disk; +pub mod gpu; +pub mod mem; +pub mod temp; diff --git a/plugin/src/stats/temp.rs b/plugin/src/stats/temp.rs new file mode 100644 index 0000000..dcd5cb2 --- /dev/null +++ b/plugin/src/stats/temp.rs @@ -0,0 +1,45 @@ +use std::collections::HashMap; +use std::fs; + +#[derive(Debug)] +pub struct ThermalDevice { + pub name: String, + pub celsius: i32, +} + +pub fn read_thermal_devices() -> Vec { + let mut by_name: HashMap = HashMap::new(); + + for i in 0.. { + let temp_path = format!("/sys/class/thermal/thermal_zone{i}/temp"); + let type_path = format!("/sys/class/thermal/thermal_zone{i}/type"); + + let Ok(temp_str) = fs::read_to_string(&temp_path) else { + break; + }; + + let millic: i32 = match temp_str.trim().parse() { + Ok(v) => v, + Err(_) => continue, + }; + let celsius = millic / 1000; + + let name = fs::read_to_string(&type_path) + .map_or_else(|_| format!("zone{i}"), |s| s.trim().to_string()); + + // Keep the highest temp seen for each device type + let entry = by_name.entry(name).or_insert(celsius); + if celsius > *entry { + *entry = celsius; + } + } + + let mut devices: Vec = by_name + .into_iter() + .map(|(name, celsius)| ThermalDevice { name, celsius }) + .collect(); + + // Sort descending by temp so the hottest shows first + devices.sort_by(|a, b| b.celsius.cmp(&a.celsius)); + devices +} diff --git a/plugin/src/system_stats.rs b/plugin/src/system_stats.rs new file mode 100644 index 0000000..6e5f295 --- /dev/null +++ b/plugin/src/system_stats.rs @@ -0,0 +1,225 @@ +use crate::stats::{disk, gpu, mem, temp}; +use core::pin::Pin; +use cxx_qt::CxxQtType; +use cxx_qt_lib::{QList, QString}; + +#[cxx_qt::bridge] +pub mod qobject { + unsafe extern "C++" { + include!("cxx-qt-lib/qstring.h"); + type QString = cxx_qt_lib::QString; + + include!("cxx-qt-lib/core/qlist/qlist_i32.h"); + type QList_i32 = cxx_qt_lib::QList; + + include!("cxx-qt-lib/core/qlist/qlist_QString.h"); + type QList_QString = cxx_qt_lib::QList; + } + + extern "RustQt" { + #[qobject] + #[qml_element] + #[qml_singleton] + // Temperature + #[qproperty(i32, temp_celsius, cxx_name = "tempCelsius")] + #[qproperty(QList_i32, temp_history, cxx_name = "tempHistory")] + // JSON strings: [{name, celsius}] + #[qproperty(QList_QString, temp_devices, cxx_name = "tempDevices")] + // GPU + #[qproperty(bool, gpu_available, cxx_name = "gpuAvailable")] + #[qproperty(QString, gpu_vendor, cxx_name = "gpuVendor")] + #[qproperty(i32, gpu_usage, cxx_name = "gpuUsage")] + #[qproperty(f64, gpu_vram_used_gb, cxx_name = "gpuVramUsedGb")] + #[qproperty(f64, gpu_vram_total_gb, cxx_name = "gpuVramTotalGb")] + #[qproperty(i32, gpu_temp_c, cxx_name = "gpuTempC")] + #[qproperty(QList_i32, gpu_history, cxx_name = "gpuHistory")] + // Memory + #[qproperty(i32, mem_percent, cxx_name = "memPercent")] + #[qproperty(f64, mem_used_gb, cxx_name = "memUsedGb")] + #[qproperty(f64, mem_total_gb, cxx_name = "memTotalGb")] + #[qproperty(f64, mem_avail_gb, cxx_name = "memAvailGb")] + #[qproperty(f64, mem_cached_gb, cxx_name = "memCachedGb")] + #[qproperty(f64, mem_buffers_gb, cxx_name = "memBuffersGb")] + #[qproperty(QList_i32, mem_history, cxx_name = "memHistory")] + // Disk: JSON strings: [{target, pct, usedBytes, totalBytes}] + #[qproperty(QList_QString, disk_mounts, cxx_name = "diskMounts")] + #[qproperty(i32, disk_root_pct, cxx_name = "diskRootPct")] + type SystemStatsService = super::SystemStatsServiceRust; + + #[qinvokable] + fn poll(self: Pin<&mut Self>); + + #[qinvokable] + #[cxx_name = "pollDisk"] + fn poll_disk_invokable(self: Pin<&mut Self>); + } + + impl cxx_qt::Initialize for SystemStatsService {} +} + +pub struct SystemStatsServiceRust { + history_temp: Vec, + history_gpu: Vec, + history_mem: Vec, + gpu_backend: gpu::GpuBackend, + + temp_celsius: i32, + temp_history: QList, + temp_devices: QList, + gpu_available: bool, + gpu_vendor: QString, + gpu_usage: i32, + gpu_vram_used_gb: f64, + gpu_vram_total_gb: f64, + gpu_temp_c: i32, + gpu_history: QList, + mem_percent: i32, + mem_used_gb: f64, + mem_total_gb: f64, + mem_avail_gb: f64, + mem_cached_gb: f64, + mem_buffers_gb: f64, + mem_history: QList, + disk_mounts: QList, + disk_root_pct: i32, +} + +impl Default for SystemStatsServiceRust { + fn default() -> Self { + Self { + history_temp: Vec::new(), + history_gpu: Vec::new(), + history_mem: Vec::new(), + gpu_backend: gpu::GpuBackend::None, + temp_celsius: 0, + temp_history: QList::default(), + temp_devices: QList::default(), + gpu_available: false, + gpu_vendor: QString::default(), + gpu_usage: 0, + gpu_vram_used_gb: 0.0, + gpu_vram_total_gb: 0.0, + gpu_temp_c: 0, + gpu_history: QList::default(), + mem_percent: 0, + mem_used_gb: 0.0, + mem_total_gb: 0.0, + mem_avail_gb: 0.0, + mem_cached_gb: 0.0, + mem_buffers_gb: 0.0, + mem_history: QList::default(), + disk_mounts: QList::default(), + disk_root_pct: 0, + } + } +} + +impl cxx_qt::Initialize for qobject::SystemStatsService { + fn initialize(mut self: Pin<&mut Self>) { + let backend = gpu::detect_gpu(); + let available = !matches!(backend, gpu::GpuBackend::None); + let vendor = match &backend { + gpu::GpuBackend::Amd { .. } => "amd", + gpu::GpuBackend::Nvidia => "nvidia", + gpu::GpuBackend::Intel { .. } => "intel", + gpu::GpuBackend::None => "", + }; + self.as_mut().rust_mut().gpu_backend = backend; + self.as_mut().set_gpu_available(available); + self.as_mut().set_gpu_vendor(QString::from(vendor)); + } +} + +impl qobject::SystemStatsService { + fn poll(mut self: Pin<&mut Self>) { + self.as_mut().poll_mem(); + self.as_mut().poll_temp(); + self.as_mut().poll_gpu(); + } + + fn poll_disk_invokable(mut self: Pin<&mut Self>) { + self.as_mut().poll_disk(); + } + + fn poll_mem(mut self: Pin<&mut Self>) { + let Some(m) = mem::read_meminfo() else { return }; + let pct = m.percent as i32; + self.as_mut().set_mem_percent(pct); + self.as_mut().set_mem_used_gb(m.used_gb); + self.as_mut().set_mem_total_gb(m.total_gb); + self.as_mut().set_mem_avail_gb(m.avail_gb); + self.as_mut().set_mem_cached_gb(m.cached_gb); + self.as_mut().set_mem_buffers_gb(m.buffers_gb); + + push_history(&mut self.as_mut().rust_mut().history_mem, pct, 30); + let hist = vec_to_qlist_i32(&self.as_ref().rust().history_mem); + self.as_mut().set_mem_history(hist); + } + + fn poll_temp(mut self: Pin<&mut Self>) { + let devices = temp::read_thermal_devices(); + if devices.is_empty() { + return; + } + let max = devices.iter().map(|d| d.celsius).max().unwrap_or(0); + self.as_mut().set_temp_celsius(max); + + push_history(&mut self.as_mut().rust_mut().history_temp, max, 150); + let hist = vec_to_qlist_i32(&self.as_ref().rust().history_temp); + self.as_mut().set_temp_history(hist); + + let mut list = QList::::default(); + for d in &devices { + let json = format!("{{\"name\":{:?},\"celsius\":{}}}", d.name, d.celsius); + list.append(QString::from(json.as_str())); + } + self.as_mut().set_temp_devices(list); + } + + fn poll_gpu(mut self: Pin<&mut Self>) { + let info = gpu::read_gpu(&mut self.as_mut().rust_mut().gpu_backend); + let Some(g) = info else { return }; + let usage = g.usage as i32; + self.as_mut().set_gpu_usage(usage); + self.as_mut().set_gpu_vram_used_gb(g.vram_used_gb); + self.as_mut().set_gpu_vram_total_gb(g.vram_total_gb); + self.as_mut().set_gpu_temp_c(g.temp_c); + + push_history(&mut self.as_mut().rust_mut().history_gpu, usage, 60); + let hist = vec_to_qlist_i32(&self.as_ref().rust().history_gpu); + self.as_mut().set_gpu_history(hist); + } + + fn poll_disk(mut self: Pin<&mut Self>) { + let mounts = disk::read_disk_mounts(); + let mut root_pct = 0i32; + let mut list = QList::::default(); + for m in &mounts { + if m.target == "/" { + root_pct = m.pct as i32; + } + let json = format!( + "{{\"target\":{:?},\"pct\":{},\"usedBytes\":{},\"totalBytes\":{}}}", + m.target, m.pct as i32, m.used_bytes as i64, m.total_bytes as i64 + ); + list.append(QString::from(json.as_str())); + } + self.as_mut().set_disk_root_pct(root_pct); + self.as_mut().set_disk_mounts(list); + } +} + +fn push_history(history: &mut Vec, value: i32, max_len: usize) { + history.push(value); + if history.len() > max_len { + history.drain(..history.len() - max_len); + } +} + +fn vec_to_qlist_i32(v: &[i32]) -> QList { + let mut list = QList::::default(); + for &x in v { + list.append(x); + } + list +} diff --git a/shell/services/CpuService.qml b/shell/services/CpuService.qml index bb5a8a1..71bec6b 100644 --- a/shell/services/CpuService.qml +++ b/shell/services/CpuService.qml @@ -1,160 +1,50 @@ pragma Singleton import QtQuick -import Quickshell.Io +import NovaStats as NS import "." as S QtObject { id: root - property int usage: 0 - property real freqGhz: 0 - property var cores: [] // [{usage, freq_ghz, history:[]}] - history only while coreConsumers > 0 - property var coreMaxFreq: [] - property var coreTypes: [] + readonly property int usage: NS.CpuService.usage + readonly property real freqGhz: NS.CpuService.freqGhz + // cores arrives as QList JSON; parse into [{usage, freq_ghz, history:[]}] + readonly property var cores: { + let raw = NS.CpuService.cores; + let out = []; + for (let i = 0; i < raw.length; i++) + out.push(JSON.parse(raw[i])); + return out; + } + readonly property var coreMaxFreq: NS.CpuService.coreMaxFreq + readonly property var coreTypes: NS.CpuService.coreTypes - // Overall CPU history (60 samples) - property var history: [] + readonly property var history: NS.CpuService.history - // Per-core data gating - applets increment/decrement + // Gate per-core history: applets increment/decrement coreConsumers property int coreConsumers: 0 onCoreConsumersChanged: { - if (coreConsumers > 0) - _coreGraceTimer.stop(); - else + NS.CpuService.enableCoreHistory = coreConsumers > 0; + if (coreConsumers <= 0) _coreGraceTimer.start(); + else + _coreGraceTimer.stop(); } property Timer _coreGraceTimer: Timer { interval: 30000 - onTriggered: root.cores = root.cores.map(() => ({ - "usage": 0, - "freq_ghz": 0, - "history": [] - })) + onTriggered: NS.CpuService.clearCoreHistory() } - // nova-stats process (cpu only) - property Process _proc: Process { - running: true - command: { + property Timer _pollTimer: Timer { + interval: { const ms = S.Modules.statsDaemon.interval; - return ms > 0 ? ["nova-stats", "--types", "cpu", "--interval", ms.toString()] : ["nova-stats", "--types", "cpu"]; + return ms > 0 ? ms : 4000; } - stdout: SplitParser { - splitMarker: "\n" - onRead: line => { - try { - const ev = JSON.parse(line); - if (ev.type !== "cpu") - return; - root.usage = ev.usage; - root.freqGhz = ev.freq_ghz; - - const h = root.history.concat([ev.usage]); - root.history = h.length > 60 ? h.slice(h.length - 60) : h; - - if (root.coreConsumers > 0) { - const histLen = 16; - const prev = root.cores; - root.cores = ev.cores.map((c, i) => { - const oldHist = prev[i]?.history ?? []; - const hist = oldHist.concat([c.usage]); - return { - usage: c.usage, - freq_ghz: c.freq_ghz, - history: hist.length > histLen ? hist.slice(hist.length - histLen) : hist - }; - }); - } else if (root.cores.length !== ev.cores.length) { - root.cores = ev.cores.map(c => ({ - "usage": c.usage, - "freq_ghz": c.freq_ghz, - "history": [] - })); - } - } catch (e) {} - } - } - } - - // One-time: per-core max freq - property Process _maxFreqProc: Process { running: true - command: ["sh", "-c", "ls -d /sys/devices/system/cpu/cpu[0-9]* 2>/dev/null | sort -V | while read d; do f=\"$d/cpufreq/cpuinfo_max_freq\"; [ -f \"$f\" ] && cat \"$f\" || echo 0; done"] - stdout: StdioCollector { - onStreamFinished: { - root.coreMaxFreq = text.trim().split("\n").filter(l => l).map(l => parseInt(l) / 1e6); - } - } + repeat: true + triggeredOnStart: true + onTriggered: NS.CpuService.poll() } - - // One-time: P/E-core topology - property Process _coreTypesProc: Process { - running: true - command: ["sh", "-c", String.raw` - if [ -f /sys/devices/cpu_core/cpus ] && [ -f /sys/devices/cpu_atom/cpus ]; then - core=$(cat /sys/devices/cpu_core/cpus) - atom=$(cat /sys/devices/cpu_atom/cpus) - echo "hybrid:$core:$atom" - exit 0 - fi - ls -d /sys/devices/system/cpu/cpu[0-9]* 2>/dev/null | sort -V | while read d; do - f="$d/topology/core_type" - [ -f "$f" ] && cat "$f" - done - `] - stdout: StdioCollector { - onStreamFinished: { - const out = text.trim(); - if (!out) - return; - if (out.startsWith("hybrid:")) { - const parts = out.split(":"); - const coreRange = parts[1]; - const atomRange = parts[2]; - function expandRange(s) { - const cpus = new Set(); - for (const part of s.split(",")) { - if (part.includes("-")) { - const [a, b] = part.split("-").map(Number); - for (let i = a; i <= b; i++) - cpus.add(i); - } else { - cpus.add(Number(part)); - } - } - return cpus; - } - const pCores = expandRange(coreRange); - const eCores = expandRange(atomRange); - const maxCpu = Math.max(...pCores, ...eCores); - const types = []; - for (let i = 0; i <= maxCpu; i++) - types.push(eCores.has(i) ? "Efficiency" : "Performance"); - root.coreTypes = types; - } else { - const types = out.split("\n").filter(l => l).map(l => l.trim()); - if (types.length > 0) - root.coreTypes = types; - } - } - } - } - - // Fallback: infer P/E from max freq gap - function _inferCoreTypesFromFreq() { - if (coreTypes.length > 0 || coreMaxFreq.length < 2) - return; - const freqs = coreMaxFreq.filter(f => f > 0); - if (!freqs.length) - return; - const maxF = Math.max(...freqs); - const minF = Math.min(...freqs); - if (maxF > 0 && minF > 0 && (maxF - minF) / maxF > 0.15) { - const threshold = (maxF + minF) / 2; - coreTypes = coreMaxFreq.map(f => f >= threshold ? "Performance" : "Efficiency"); - } - } - onCoreMaxFreqChanged: Qt.callLater(_inferCoreTypesFromFreq) } diff --git a/shell/services/SystemStats.qml b/shell/services/SystemStats.qml index bae2550..76dd9cd 100644 --- a/shell/services/SystemStats.qml +++ b/shell/services/SystemStats.qml @@ -1,117 +1,71 @@ pragma Singleton import QtQuick -import Quickshell.Io +import NovaStats as NS import "." as M QtObject { id: root // ── Temperature ────────────────────────────────────────────────────── - property int tempCelsius: 0 - property var tempHistory: [] // 150 samples @ 4s each ≈ 10 min - property var tempDevices: [] // [{name, celsius}] sorted hottest-first + readonly property int tempCelsius: NS.SystemStatsService.tempCelsius + readonly property var tempHistory: NS.SystemStatsService.tempHistory + // tempDevices arrives as QList JSON; parse into [{name, celsius}] + readonly property var tempDevices: { + let raw = NS.SystemStatsService.tempDevices; + let out = []; + for (let i = 0; i < raw.length; i++) + out.push(JSON.parse(raw[i])); + return out; + } // ── GPU ────────────────────────────────────────────────────────────── - property bool gpuAvailable: false - property string gpuVendor: "" - property int gpuUsage: 0 - property real gpuVramUsedGb: 0 - property real gpuVramTotalGb: 0 - property int gpuTempC: 0 - property var gpuHistory: [] // 60 samples @ ~4-8s each ≈ 4-8 min + readonly property bool gpuAvailable: NS.SystemStatsService.gpuAvailable + readonly property string gpuVendor: NS.SystemStatsService.gpuVendor + readonly property int gpuUsage: NS.SystemStatsService.gpuUsage + readonly property real gpuVramUsedGb: NS.SystemStatsService.gpuVramUsedGb + readonly property real gpuVramTotalGb: NS.SystemStatsService.gpuVramTotalGb + readonly property int gpuTempC: NS.SystemStatsService.gpuTempC + readonly property var gpuHistory: NS.SystemStatsService.gpuHistory // ── Memory ─────────────────────────────────────────────────────────── - property int memPercent: 0 - property real memUsedGb: 0 - property real memTotalGb: 0 - property real memAvailGb: 0 - property real memCachedGb: 0 - property real memBuffersGb: 0 - property var memHistory: [] + readonly property int memPercent: NS.SystemStatsService.memPercent + readonly property real memUsedGb: NS.SystemStatsService.memUsedGb + readonly property real memTotalGb: NS.SystemStatsService.memTotalGb + readonly property real memAvailGb: NS.SystemStatsService.memAvailGb + readonly property real memCachedGb: NS.SystemStatsService.memCachedGb + readonly property real memBuffersGb: NS.SystemStatsService.memBuffersGb + readonly property var memHistory: NS.SystemStatsService.memHistory // ── Disk ───────────────────────────────────────────────────────────── - property var diskMounts: [] - property int diskRootPct: 0 + // diskMounts arrives as QList JSON; parse into [{target, pct, usedBytes, totalBytes}] + readonly property var diskMounts: { + let raw = NS.SystemStatsService.diskMounts; + let out = []; + for (let i = 0; i < raw.length; i++) + out.push(JSON.parse(raw[i])); + return out; + } + readonly property int diskRootPct: NS.SystemStatsService.diskRootPct - // nova-stats stream (mem, temp, gpu - cpu handled by CpuService) - property var _statsProc: Process { - running: true - command: { + // ── Polling ────────────────────────────────────────────────────────── + // Drive the Rust service from QML timers; both intervals read from Modules config. + property Timer _statsTimer: Timer { + interval: { const ms = M.Modules.statsDaemon.interval; - return ms > 0 ? ["nova-stats", "--types", "mem,temp,gpu", "--interval", ms.toString()] : ["nova-stats", "--types", "mem,temp,gpu"]; + return ms > 0 ? ms : 4000; } - stdout: SplitParser { - splitMarker: "\n" - onRead: line => { - try { - const ev = JSON.parse(line); - if (ev.type === "temp") { - root.tempCelsius = ev.celsius; - const th = root.tempHistory.concat([ev.celsius]); - root.tempHistory = th.length > 150 ? th.slice(th.length - 150) : th; - if (ev.devices) - root.tempDevices = ev.devices; - } else if (ev.type === "gpu") { - root.gpuAvailable = true; - root.gpuVendor = ev.vendor; - root.gpuUsage = ev.usage; - root.gpuVramUsedGb = ev.vram_used_gb; - root.gpuVramTotalGb = ev.vram_total_gb; - root.gpuTempC = ev.temp_c; - const gh = root.gpuHistory.concat([ev.usage]); - root.gpuHistory = gh.length > 60 ? gh.slice(gh.length - 60) : gh; - } else if (ev.type === "mem") { - root.memPercent = ev.percent; - root.memUsedGb = ev.used_gb; - root.memTotalGb = ev.total_gb; - root.memAvailGb = ev.avail_gb; - root.memCachedGb = ev.cached_gb; - root.memBuffersGb = ev.buffers_gb; - const h = root.memHistory.concat([ev.percent]); - root.memHistory = h.length > 30 ? h.slice(h.length - 30) : h; - } - } catch (e) {} - } - } - } - - // Disk via df - property var _diskProc: Process { - id: diskProc running: true - command: ["sh", "-c", "df -x tmpfs -x devtmpfs -x squashfs -x efivarfs -x overlay -B1 --output=target,size,used 2>/dev/null | awk 'NR>1 && $2+0>0 {print $1\"|\"$2\"|\"$3}'"] - stdout: StdioCollector { - onStreamFinished: { - const lines = text.trim().split("\n").filter(l => l); - const mounts = []; - for (const line of lines) { - const parts = line.split("|"); - if (parts.length < 3) - continue; - const total = parseInt(parts[1]); - const used = parseInt(parts[2]); - if (total <= 0) - continue; - mounts.push({ - "target": parts[0], - "pct": Math.round(used / total * 100), - "usedBytes": used, - "totalBytes": total - }); - } - root.diskMounts = mounts; - const rm = mounts.find(m => m.target === "/"); - if (rm) - root.diskRootPct = rm.pct; - } - } + repeat: true + triggeredOnStart: true + onTriggered: NS.SystemStatsService.poll() } - property var _diskTimer: Timer { + property Timer _diskTimer: Timer { interval: M.Modules.disk.interval || 30000 running: true repeat: true - onTriggered: diskProc.running = true + triggeredOnStart: true + onTriggered: NS.SystemStatsService.pollDisk() } }